Search…
Low Level Design · Part 14

API design and contract-first development

In this series (15 parts)
  1. Introduction to low level design
  2. SOLID principles
  3. Design patterns: Creational
  4. Design patterns: Structural
  5. Design patterns: Behavioral
  6. Designing a parking lot
  7. Designing a library management system
  8. Designing an elevator system
  9. Designing a hotel booking system
  10. Designing a ride-sharing model
  11. Designing a rate limiter
  12. Designing a logging framework
  13. Designing a notification system
  14. API design and contract-first development
  15. 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:

  1. 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.

  2. Code generation. Tools like openapi-generator produce client SDKs, server stubs, and validation middleware from the schema. Less handwritten code means fewer bugs.

  3. 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 StatusError CodeMeaning
400VALIDATION_FAILEDRequest body failed schema validation
401UNAUTHORIZEDMissing or invalid authentication
403FORBIDDENAuthenticated but not authorized
404NOT_FOUNDResource does not exist
409CONFLICTResource state conflict (duplicate)
429RATE_LIMITEDToo many requests
500INTERNAL_ERRORUnexpected 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.

Start typing to search across all content
navigate Enter open Esc close