API design and contract-first development
In this series (15 parts)
- Introduction to low level design
- SOLID principles
- Design patterns: Creational
- Design patterns: Structural
- Design patterns: Behavioral
- Designing a parking lot
- Designing a library management system
- Designing an elevator system
- Designing a hotel booking system
- Designing a ride-sharing model
- Designing a rate limiter
- Designing a logging framework
- Designing a notification system
- API design and contract-first development
- Data modeling for system design
Most teams write the code first and generate API documentation after. This leads to APIs shaped by implementation accidents rather than consumer needs. Contract-first flips this. You design the API schema, get agreement from consumers, then implement to match the contract. The schema becomes the source of truth that drives code generation, validation, testing, and documentation.
For the high-level principles of API design, start there. This article focuses on the low-level details: how to structure your OpenAPI spec, model request and response objects, design error envelopes, handle versioning, and implement pagination.
Why contract-first
Three concrete benefits:
-
Parallel development. Frontend and backend teams work simultaneously. The frontend mocks the API from the schema. The backend implements against it. Integration happens at the end, not throughout.
-
Code generation. Tools like
openapi-generatorproduce client SDKs, server stubs, and validation middleware from the schema. Less handwritten code means fewer bugs. -
Testable contracts. Contract tests verify that the implementation matches the schema. If someone changes the response format, the test breaks before it reaches production.
OpenAPI schema structure
An OpenAPI spec has three main sections: paths (the endpoints), components (reusable schemas), and security definitions. Here is a minimal but complete example for a notification API.
openapi: 3.0.3
info:
title: Notification API
version: 1.0.0
paths:
/notifications:
post:
operationId: sendNotification
summary: Send a notification
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SendNotificationRequest'
responses:
'202':
description: Notification accepted for delivery
content:
application/json:
schema:
$ref: '#/components/schemas/NotificationResponse'
'400':
$ref: '#/components/responses/BadRequest'
'429':
$ref: '#/components/responses/RateLimited'
/notifications/{notificationId}:
get:
operationId: getNotificationStatus
summary: Get notification delivery status
parameters:
- name: notificationId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Notification status
content:
application/json:
schema:
$ref: '#/components/schemas/NotificationStatusResponse'
'404':
$ref: '#/components/responses/NotFound'
Every response references a reusable schema. Every error has a standard envelope. This consistency is what makes an API predictable.
Request and response modeling
Model your request and response objects as explicit schemas with validation constraints. Do not accept arbitrary JSON.
erDiagram
SendNotificationRequest {
string type "required, enum"
string templateId "required, uuid"
string priority "enum: LOW, MEDIUM, HIGH, CRITICAL"
json payload "key-value pairs for template"
array recipientIds "required, min 1"
}
NotificationResponse {
string notificationId "uuid"
string status "ACCEPTED"
string createdAt "ISO-8601"
}
NotificationStatusResponse {
string notificationId "uuid"
string type "notification type"
string status "PENDING, SENT, DELIVERED, FAILED"
array deliveries "per-recipient status"
}
DeliveryDetail {
string recipientId "uuid"
string channel "EMAIL, SMS, PUSH"
string state "SENT, DELIVERED, FAILED"
int attemptCount "number of delivery attempts"
string lastAttemptAt "ISO-8601"
}
SendNotificationRequest ||--o{ NotificationResponse : "returns"
NotificationStatusResponse ||--|{ DeliveryDetail : "contains"
ER diagram showing the relationship between API request objects, response objects, and delivery details.
components:
schemas:
SendNotificationRequest:
type: object
required: [type, templateId, recipientIds]
properties:
type:
type: string
enum: [order_confirmation, password_reset, promotion, alert]
templateId:
type: string
format: uuid
priority:
type: string
enum: [LOW, MEDIUM, HIGH, CRITICAL]
default: MEDIUM
payload:
type: object
additionalProperties:
type: string
recipientIds:
type: array
items:
type: string
format: uuid
minItems: 1
maxItems: 1000
Key decisions here: recipientIds has a maxItems of 1000 to prevent abuse. priority defaults to MEDIUM so callers do not have to specify it for routine notifications. payload uses additionalProperties to accept any key-value pairs that the template needs.
Error envelopes
Every error response should follow the same structure. Clients should never have to guess the format of an error.
ErrorResponse:
type: object
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code:
type: string
description: Machine-readable error code
message:
type: string
description: Human-readable error message
details:
type: array
items:
type: object
properties:
field:
type: string
reason:
type: string
requestId:
type: string
format: uuid
An actual error response looks like this:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request validation failed",
"details": [
{
"field": "recipientIds",
"reason": "must contain at least 1 element"
},
{
"field": "templateId",
"reason": "must be a valid UUID"
}
],
"requestId": "req-abc-123"
}
}
The code field is the machine-readable identifier that clients switch on. The message is for humans reading logs. The details array pinpoints exactly which fields failed and why. The requestId ties the error back to your internal request tracing.
Define a standard set of error codes across your API:
| HTTP Status | Error Code | Meaning |
|---|---|---|
| 400 | VALIDATION_FAILED | Request body failed schema validation |
| 401 | UNAUTHORIZED | Missing or invalid authentication |
| 403 | FORBIDDEN | Authenticated but not authorized |
| 404 | NOT_FOUND | Resource does not exist |
| 409 | CONFLICT | Resource state conflict (duplicate) |
| 429 | RATE_LIMITED | Too many requests |
| 500 | INTERNAL_ERROR | Unexpected server error |
Versioning strategies
APIs evolve. Fields get added, deprecated, or renamed. Versioning determines how you manage these changes without breaking existing clients.
URL path versioning is the most common approach: /v1/notifications, /v2/notifications. It is explicit, easy to route, and simple to understand. The downside is that every new version duplicates routing logic.
Header versioning uses Accept: application/vnd.myapi.v2+json or a custom header like X-API-Version: 2. It keeps URLs clean but is harder to test in a browser and easier to forget.
Query parameter versioning uses ?version=2. Simple but clutters the URL and mixes routing concerns with query parameters.
The pragmatic choice for most teams is URL path versioning. Reserve it for breaking changes only. Additive changes (new optional fields, new endpoints) do not need a version bump.
# Breaking change checklist:
# - Removing a field: BREAKING
# - Renaming a field: BREAKING
# - Changing a field type: BREAKING
# - Adding a required field to request: BREAKING
# - Adding an optional field to request: NOT breaking
# - Adding a field to response: NOT breaking (if clients ignore unknown)
# - Removing an enum value: BREAKING
# - Adding an enum value: BREAKING (if clients validate strictly)
Pagination patterns
Any endpoint that returns a list needs pagination. Two patterns dominate: offset-based and cursor-based.
Offset pagination uses ?offset=20&limit=10. It is simple and allows jumping to any page. But it breaks when data changes between requests. If a new item is inserted at position 5 while the client is on page 3, they will see a duplicate item on page 4.
Cursor pagination uses an opaque token that points to the last item seen: ?cursor=eyJpZCI6MTAwfQ&limit=10. The server decodes the cursor, queries for items after that point, and returns the next cursor. No duplicates, no skipped items.
PaginatedResponse:
type: object
properties:
data:
type: array
items: {}
pagination:
type: object
properties:
nextCursor:
type: string
nullable: true
previousCursor:
type: string
nullable: true
hasMore:
type: boolean
totalCount:
type: integer
description: Only available for offset pagination
Implementation for cursor-based pagination:
public record CursorPage<T>(
List<T> items,
String nextCursor,
boolean hasMore
) {}
public CursorPage<Notification> listNotifications(String cursor, int limit) {
String decodedId = cursor != null ? decodeCursor(cursor) : null;
List<Notification> items = repository.findAfter(decodedId, limit + 1);
boolean hasMore = items.size() > limit;
if (hasMore) {
items = items.subList(0, limit);
}
String nextCursor = hasMore
? encodeCursor(items.get(items.size() - 1).id())
: null;
return new CursorPage<>(items, nextCursor, hasMore);
}
private String encodeCursor(String id) {
return Base64.getEncoder().encodeToString(id.getBytes());
}
private String decodeCursor(String cursor) {
return new String(Base64.getDecoder().decode(cursor));
}
The trick of fetching limit + 1 items tells you whether there are more pages without a separate count query. This is much cheaper on large tables where COUNT(*) is expensive.
sequenceDiagram participant C as Client participant A as API Server participant DB as Database C->>A: GET /notifications?limit=10 A->>DB: SELECT * FROM notifications ORDER BY id LIMIT 11 DB-->>A: 11 rows A->>A: hasMore = true, trim to 10 A->>A: nextCursor = encode(last item id) A-->>C: 200 OK with data + nextCursor C->>A: GET /notifications?cursor=abc123&limit=10 A->>A: decode cursor to get last seen id A->>DB: SELECT * FROM notifications WHERE id > lastId ORDER BY id LIMIT 11 DB-->>A: 7 rows A->>A: hasMore = false A-->>C: 200 OK with data + no nextCursor
Sequence diagram showing cursor-based pagination across two consecutive requests.
Putting it together: API design checklist
Before shipping any new endpoint, verify:
- Request body has explicit required fields and validation constraints
- Response uses a consistent envelope (data wrapper for success, error wrapper for failures)
- Error responses include machine-readable codes, human-readable messages, and field-level details
- List endpoints use cursor pagination unless random access is a hard requirement
- Rate limit headers are present on every response
- The endpoint is documented in the OpenAPI spec before implementation begins
- Breaking changes increment the API version
What comes next
APIs move data between services, but the shape of that data is determined by your data model. The next article on data modeling for system design covers how to design relational schemas, decide when to normalize or denormalize, choose indexing strategies, and model data for both SQL and NoSQL stores.