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.
# 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'
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.
# 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
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.
# 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"
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.
// 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);
}
}
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.
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()
);
}
}
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.
# .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
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.