Design-first API development inverts the traditional workflow. Instead of writing code and deriving documentation from it, you design the API contract first, then implement code that fulfills that contract. This approach produces more consistent APIs, enables parallel development, and catches design problems before implementation begins.
The traditional code-first approach seems efficient; write the code, generate docs from annotations. But it often produces APIs that reflect implementation details rather than consumer needs. The database schema leaks through. Internal naming conventions become external contracts. Changes to implementation accidentally become breaking API changes.
Design-first forces you to think from the consumer's perspective before implementation biases your thinking. What does the client need? What's the clearest way to express that? How will this evolve? These questions are easier to answer before you've written code you're reluctant to change.
The Design-First Workflow
A design-first workflow typically follows these stages: design the API specification, review and iterate on the design, generate code stubs and documentation, implement the specification, and validate implementation matches specification.
The API specification becomes the source of truth. OpenAPI (formerly Swagger) is the dominant format, providing a machine-readable contract that drives documentation, code generation, and validation.
Here's an example of an OpenAPI specification that serves as the contract for a client portal API. You'll notice it includes design principles in the description, uses reusable components for consistency, and documents both successful responses and error cases.
# api/openapi.yaml - The source of truth
openapi: 3.1.0
info:
title: Client Portal API
version: 1.0.0
description: |
API for managing clients, projects, and invoices.
## Design Principles
- RESTful resource-oriented design
- Consistent error responses
- Cursor-based pagination for lists
- ISO 8601 timestamps
paths:
/clients:
get:
operationId: listClients
summary: List all clients
description: |
Returns a paginated list of clients. Results are ordered by
creation date descending (newest first).
parameters:
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageCursor'
- name: status
in: query
description: Filter by client status
schema:
$ref: '#/components/schemas/ClientStatus'
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ClientList'
'401':
$ref: '#/components/responses/Unauthorized'
post:
operationId: createClient
summary: Create a new client
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateClientRequest'
responses:
'201':
description: Client created
content:
application/json:
schema:
$ref: '#/components/schemas/Client'
'400':
$ref: '#/components/responses/ValidationError'
'401':
$ref: '#/components/responses/Unauthorized'
The $ref syntax keeps the specification DRY by reusing common components. This also ensures consistency across endpoints since they all reference the same schema definitions.
Design Reviews
Design reviews catch problems before implementation. Stakeholders; frontend developers, mobile developers, external API consumers; review the specification and provide feedback. Is the naming clear? Are the request/response shapes convenient? Does pagination work for their use case?
Written specifications enable asynchronous review across time zones and schedules. Comments on the specification become a record of design decisions and their rationale.
You can embed design decision documentation directly in your schemas. This helps future maintainers understand why certain choices were made and prevents well-intentioned refactors from breaking backward compatibility.
# Design decisions documented in the spec
components:
schemas:
Client:
type: object
description: |
Represents a client organization.
## Design Decisions
- `company_name` rather than `name` to avoid confusion with contact names
- `status` uses an enum rather than boolean `is_active` to support
future states like 'pending', 'suspended'
- Nested `contact` object groups contact information together
properties:
id:
type: integer
format: int64
readOnly: true
company_name:
type: string
maxLength: 255
status:
$ref: '#/components/schemas/ClientStatus'
contact:
$ref: '#/components/schemas/ContactInfo'
created_at:
type: string
format: date-time
readOnly: true
These embedded design decisions serve as living documentation. When someone asks "why is it company_name instead of name?", the answer is right there in the specification.
Parallel Development
With an agreed specification, frontend and backend teams can work in parallel. Frontend developers build against mock servers generated from the spec. Backend developers implement the real endpoints. Both converge when implementation is complete.
Mock servers provide realistic responses without backend implementation. Tools like Prism, Stoplight, and Swagger UI can serve mock responses based on examples in the specification.
Adding rich examples to your specification enables useful mock servers. Include examples for different scenarios so frontend developers can test against realistic data while you're still building the backend.
# Rich examples enable useful mocks
paths:
/clients/{id}:
get:
operationId: getClient
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/Client'
examples:
active_client:
summary: An active client
value:
id: 123
company_name: "Acme Corporation"
status: "active"
contact:
name: "John Smith"
email: "john@acme.com"
phone: "+1-555-123-4567"
created_at: "2024-01-15T10:30:00Z"
pending_client:
summary: A pending client
value:
id: 456
company_name: "NewCo Inc"
status: "pending"
contact:
name: "Jane Doe"
email: "jane@newco.com"
created_at: "2024-02-01T14:00:00Z"
Multiple examples help frontend developers handle different states in the UI. They can switch between the "active_client" and "pending_client" examples to verify their UI handles both cases correctly.
Code Generation
Specifications can generate server stubs, client SDKs, and validation logic. This ensures consistency between specification and implementation while reducing boilerplate code.
Server-side code generation creates route handlers, request validation, and response serialization based on the specification. The generated code enforces the contract; requests that don't match are rejected, responses that don't match cause errors.
The following example shows how code generation can produce an abstract base controller that enforces the API contract. Your implementation extends this base class, inheriting the validation and type safety automatically.
// Generated from OpenAPI spec
namespace App\Http\Controllers\Api\Generated;
abstract class ClientControllerBase extends Controller
{
/**
* List all clients
* GET /clients
*
* @param ListClientsRequest $request Validated request
* @return ClientListResponse
*/
abstract public function listClients(ListClientsRequest $request): ClientListResponse;
/**
* Create a new client
* POST /clients
*
* @param CreateClientRequest $request Validated request
* @return ClientResponse
*/
abstract public function createClient(CreateClientRequest $request): ClientResponse;
}
// Your implementation extends the generated base
class ClientController extends ClientControllerBase
{
public function listClients(ListClientsRequest $request): ClientListResponse
{
$clients = Client::query()
->when($request->status, fn($q, $status) => $q->where('status', $status))
->cursorPaginate($request->page_size);
return new ClientListResponse($clients);
}
public function createClient(CreateClientRequest $request): ClientResponse
{
$client = Client::create($request->validated());
return new ClientResponse($client, 201);
}
}
The generated request and response classes include validation logic derived from the specification. If your implementation tries to return a response that doesn't match the schema, you'll get an error during development rather than in production.
Client SDK generation creates typed clients in various languages. API consumers get compile-time type checking and IDE autocomplete, reducing integration errors.
Contract Testing
Contract tests verify that implementations match specifications. Every endpoint should be tested against its specification: correct status codes, response schemas, header handling, and error formats.
The following test class demonstrates how to validate your API implementation against the OpenAPI specification. Each test calls an endpoint and verifies that both the request and response conform to the documented contract.
class ApiContractTest extends TestCase
{
private OpenApiValidator $validator;
protected function setUp(): void
{
parent::setUp();
$this->validator = new OpenApiValidator(
base_path('api/openapi.yaml')
);
}
public function test_list_clients_matches_spec(): void
{
$response = $this->getJson('/api/clients');
$this->validator->validate(
method: 'GET',
path: '/clients',
statusCode: $response->status(),
responseBody: $response->json()
);
}
public function test_create_client_matches_spec(): void
{
$payload = [
'company_name' => 'Test Corp',
'contact' => [
'name' => 'Test User',
'email' => 'test@example.com',
],
];
$response = $this->postJson('/api/clients', $payload);
$this->validator->validate(
method: 'POST',
path: '/clients',
statusCode: $response->status(),
requestBody: $payload,
responseBody: $response->json()
);
}
public function test_validation_error_matches_spec(): void
{
// Invalid request should return spec-compliant error
$response = $this->postJson('/api/clients', [
'company_name' => '', // Invalid: empty
]);
$response->assertStatus(400);
$this->validator->validate(
method: 'POST',
path: '/clients',
statusCode: 400,
responseBody: $response->json()
);
}
}
Run these contract tests in your CI pipeline to catch specification drift. If someone changes the implementation without updating the spec (or vice versa), these tests will fail.
Evolving Specifications
APIs evolve over time. Design-first makes evolution explicit. Changes to the specification are visible in code review. Breaking changes are obvious. Versioning decisions are intentional.
Specification linting catches common mistakes and enforces consistency. Rules might require descriptions on all endpoints, examples on all schemas, or consistent naming conventions.
The following Spectral configuration defines linting rules that enforce consistency across your API specification. These rules run in CI to catch issues before they're merged.
# .spectral.yaml - API linting rules
extends: spectral:oas
rules:
operation-description:
description: Operations must have descriptions
given: "$.paths[*][get,post,put,patch,delete]"
then:
field: description
function: truthy
schema-examples:
description: Schemas should have examples
given: "$.components.schemas[*]"
then:
field: example
function: truthy
consistent-naming:
description: Use camelCase for property names
given: "$.components.schemas..properties[*]~"
then:
function: casing
functionOptions:
type: camel
Linting rules codify your API design standards. New team members learn the conventions automatically because violations fail the build with clear explanations.
When Design-First Works Best
Design-first adds overhead. Writing specifications before code takes time. Maintaining synchronization requires discipline. For simple internal APIs or rapid prototypes, the overhead may not be justified.
Design-first shines when APIs are consumed by multiple clients, when frontend and backend teams work in parallel, when APIs will be public or long-lived, or when consistency and quality matter more than speed.
The discipline of design-first pays dividends over time. APIs designed thoughtfully up front require fewer breaking changes. Consumers have reliable contracts they can depend on. Documentation stays accurate because it's the source of truth, not a generated afterthought.
Conclusion
Design-first API development produces better APIs by separating design decisions from implementation details. The specification becomes the contract between teams, enabling parallel development, contract testing, and code generation. Changes are explicit and reviewable.
The upfront investment in specification design pays off in reduced rework, better consumer experience, and APIs that remain coherent as they evolve. For APIs that matter; public APIs, APIs used by multiple teams, APIs that will exist for years; design-first is worth the discipline.