Spec Driven Development Part 3: Specs for APIs & Data Contracts
11 min read

Spec Driven Development Part 3: Specs for APIs & Data Contracts

Part 2 discussed how to write specs for features — intent, constraints, acceptance criteria, and non-goals aimed at one piece of work done once. But there’s one spec category with different characteristics: specs for APIs and data schemas. The difference isn’t in the format, but in the consequences. An ambiguous feature spec at worst produces a wrong implementation that needs revision. An ambiguous API contract can make the frontend and backend build different assumptions in parallel, or worse, create a breaking change damaging external clients you don’t even know exist. This article discusses how to write specs for APIs and data as truly executable contracts — automatically validatable, not just documentation read then ignored.

Why Data Contracts Differ from Regular Feature Specs

A regular feature spec has one main “consumer”: the agent or developer implementing that feature. Once the feature is done and verified, the spec has completed its job — although it remains useful as historical documentation.

API contracts and data schemas differ because they have many consumers working in parallel and independently:

  • Frontend teams building UIs based on assumptions about the response shape
  • Other backend teams consuming this API as part of their services
  • External clients or partners integrating their systems with this API
  • AI agents generating code on both sides — sometimes the same agent writes the server and client, sometimes different agents unaware of each other’s assumptions

Because many parties depend on the same contract, ambiguity here is far more expensive. If the password reset feature spec from Part 2 is ambiguous about “how long the token expires”, the impact is limited to one feature. If an API spec is ambiguous about “is the email field always present in the response or can it be null”, the impact spreads to every piece of code that has ever consumed that endpoint — and once many parties use it, fixing it becomes a breaking change that must be coordinated, not just an internal revision.

This is why data contracts need a stricter spec format and, ideally, one validatable by tooling — not just read by humans.

An ambiguous feature spec produces revisions. An ambiguous API spec produces failed integrations in production, sometimes months after the contract was written, when a new consumer appears with different assumptions from what the spec author intended.

OpenAPI as an Executable Spec

For REST APIs, OpenAPI (formerly known as Swagger) is the most mature format for writing executable contracts. The difference from regular API documentation: OpenAPI is written in a structured format (YAML or JSON) parseable by tooling, not free-form prose only readable by humans.

There are three practical advantages to making OpenAPI the spec, not the final result:

Automatic validation. Requests and responses can be validated against the schema automatically in tests and at runtime. If the implementation deviates from the contract, validation fails — no need to wait for a human reviewer to notice the mismatch.

Generate stubs and clients. From one OpenAPI file, you can generate server stubs, client SDKs, even mock servers for testing — all consistent because they come from the same source.

Documentation that never goes stale. Because documentation is derived from the same spec that’s validated, documentation can’t “forget to be updated” like a README written manually separate from the code.

What makes OpenAPI specifically relevant for SDD: an agent can read an OpenAPI file and directly understand the expected request/response shape, without needing to guess from reading existing code that might already be inconsistent.

JSON Schema for Data Validation

OpenAPI itself uses JSON Schema behind the scenes to define the shape of request and response bodies. But JSON Schema is also useful as a standalone spec — for example for data structures stored in databases, event/message queue payloads, or configuration read by applications.

The basic principle: every field must have its type defined, whether it’s required, and its value constraints — not left implicit.

{
  "type": "object",
  "required": ["id", "email", "status", "createdAt"],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "email": {
      "type": "string",
      "format": "email",
      "maxLength": 255
    },
    "status": {
      "type": "string",
      "enum": ["pending", "active", "suspended", "deleted"]
    },
    "displayName": {
      "type": ["string", "null"],
      "maxLength": 100
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    }
  },
  "additionalProperties": false
}

Notice several details deliberately made explicit here:

  • required states which fields must exist — the agent doesn’t need to guess whether email can be missing from the response
  • enum on status prevents the agent (or any implementation) from adding undocumented new status values
  • displayName is explicitly allowed to be null, unlike other fields that aren’t — this difference is often a bug source when only assumed
  • additionalProperties: false forbids unregistered extra fields, preventing the schema from silently “growing” over time
A schema leaving additionalProperties at its default (which means true) looks flexible, but this opens the door for unexpected fields to slip in without validation. For contracts that must be stable, explicitly forbidding extra fields is far safer than assuming all parties will be disciplined.

Protobuf for Inter-Service Contracts

For communication between services inside your own system — especially those sensitive to performance or needing stricter strong typing than JSON — Protocol Buffers (Protobuf) is often a more suitable choice than REST plus JSON.

The difference from OpenAPI/JSON Schema isn’t about which is “better” in general, but about usage context:

AspectREST + OpenAPI/JSONgRPC + Protobuf
Typical consumersExternal clients, browsers, API partnersInternal inter-service communication
Data typesFlexible, validated at runtimeStrict, validated at compile time
PerformanceLarger payload (JSON text-based)Smaller and faster (binary)
Schema evolutionManual, needs versioning disciplineBuilt-in (field numbers, reserved keyword)
Code generation toolingWidely available, many languagesNative, tightly integrated with gRPC

Protobuf has a special advantage for SDD: the .proto schema itself is the contract used to generate code on both sides (client and server), in many languages at once. There’s no gap between “spec” and “implementation” because both are derived from the same file.

syntax = "proto3";

message ResetPasswordRequest {
  string email = 1;
}

message ResetPasswordResponse {
  bool accepted = 1;
  string message = 2;
}

message ConfirmResetRequest {
  string token = 1;
  string new_password = 2;
}

message ConfirmResetResponse {
  bool success = 1;
  string error_code = 2; // empty if success = true
}

service PasswordResetService {
  rpc RequestReset(ResetPasswordRequest) returns (ResetPasswordResponse);
  rpc ConfirmReset(ConfirmResetRequest) returns (ConfirmResetResponse);
}

For teams not yet needing gRPC complexity, REST with OpenAPI remains a sensible default. Consider Protobuf when communication happens purely between internal services with high volume, or when strong typing across languages becomes a priority.

flowchart TD
    A{Who are the contract's consumers?} -- External clients/browser --> B[REST + OpenAPI]
    A -- Internal services --> C{High traffic volume or need cross-language strong typing?}
    C -- Yes --> D[gRPC + Protobuf]
    C -- No --> B

Writing an API Spec: An End-to-End Example

Continuing the password reset feature example from Part 2, here’s how the acceptance criteria written there are translated into a concrete OpenAPI contract.

openapi: 3.0.3
info:
  title: Password Reset API
  version: 1.0.0

paths:
  /api/v1/password-reset/request:
    post:
      summary: Submit a password reset request
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        "200":
          description: >
            Request accepted. The response is exactly the same whether the email
            is registered or not, to prevent email enumeration.            
          content:
            application/json:
              schema:
                type: object
                required: [accepted, message]
                properties:
                  accepted:
                    type: boolean
                    enum: [true]
                  message:
                    type: string
        "429":
          description: Rate limit exceeded (maximum 3 requests per email per hour)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/password-reset/confirm:
    post:
      summary: Confirm the password reset with a token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, newPassword]
              properties:
                token:
                  type: string
                  format: uuid
                newPassword:
                  type: string
                  minLength: 8
      responses:
        "200":
          description: Password changed successfully
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    enum: [true]
        "410":
          description: Token has expired or has already been used
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: The new password doesn't meet the validation policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

components:
  schemas:
    ErrorResponse:
      type: object
      required: [errorCode, message]
      properties:
        errorCode:
          type: string
        message:
          type: string

Every status code here isn’t a coincidence — each connects directly to the acceptance criteria already written in Part 2. HTTP 410 for expired tokens, HTTP 429 for rate limits, HTTP 422 for password validation — all explicit, so the agent implementing this endpoint, and other agents or developers consuming it, have exactly the same understanding of how each case should be handled.

The complete interaction flow between the client, API, and email service for these two endpoints looks like this:

sequenceDiagram
    participant Client
    participant API
    participant DB
    participant EmailService

    Client->>API: POST /password-reset/request {email}
    API->>DB: Store token (hash) + 15-minute expiry
    API->>EmailService: Send email containing the reset link
    API-->>Client: 200 {accepted: true}

    Client->>API: POST /password-reset/confirm {token, newPassword}
    API->>DB: Validate token (exists, not expired, not used)
    alt Token valid
        API->>DB: Update password, delete token
        API-->>Client: 200 {success: true}
    else Token expired/used
        API-->>Client: 410 ErrorResponse
    else Invalid password
        API-->>Client: 422 ErrorResponse
    end
Define the error response schema centrally (like ErrorResponse above) and reuse it across all endpoints. This prevents every endpoint having a different error format — a common problem when several endpoints are built by different agents or developers without a shared contract.

Versioning and Backward Compatibility as Constraints

One of the most often missing constraints in API specs is the versioning policy — how the contract may change over time without breaking existing consumers.

Without an explicit policy, an agent asked to “add a new field” or “change validation” has no guidance on whether the change is safe to do directly on the existing endpoint, or must go through a new version. Several rules that should be stated explicitly at the spec level, not assumed:

Versioning Constraints:
- Adding a new optional field to the response: SAFE, no new version needed
- Removing a field from the response: BREAKING, new version required
- Changing the data type of an existing field: BREAKING, new version required
- Changing a field from optional to required in the request: BREAKING,
  new version required
- Adding a new endpoint: SAFE, no new version needed
- Changing the behavior of an existing endpoint (even if the signature doesn't change):
  BREAKING, new version required or a feature flag

Rules like these can be considered “global constraints” applying to all API specs in one project — no need to rewrite them in every endpoint spec, but they must be documented once at the project level (for example in a conventions file read by all agents before working) and referenced from each individual spec.

For APIs that already have external consumers, also consider stating the minimum lifetime of old versions explicitly:

Lifecycle Constraints:
- Old API versions must remain supported for at least 6 months after a new
  version is released
- Deprecated endpoints must return the
  Deprecation: true and Sunset: <date> headers
- Breaking changes must not be released without at least 30 days' prior
  announcement to registered consumers

Anti-Patterns in API Specs

Several patterns often appear and weaken what should be a strict contract:

Schemas too loose. All fields marked optional, or data types left as any/object without clear structure. This looks flexible but actually moves the validation burden to every consumer, each guessing in its own way.

ANTI-PATTERN:
properties:
  data:
    type: object
    description: "Response data, varying structure"

CORRECT:
properties:
  data:
    type: object
    required: [id, status]
    properties:
      id:
        type: string
        format: uuid
      status:
        type: string
        enum: [pending, completed, failed]

Not defining error responses. Many API specs only define success responses and ignore the error shape, even though from the consumer’s side, handling errors correctly is as important as handling success. Without a clear error schema, each endpoint tends to return a different error format.

Not stating the versioning policy. As discussed above — without explicit rules, breaking changes can slip in unintentionally, especially when several agents or developers work on the same endpoint at different times.

Documenting behavior, not the contract. A spec explaining “this endpoint does X, Y, Z internally” instead of defining the request/response shape that must be obeyed. Internal implementation details shouldn’t enter the API contract — the contract only cares about what’s visible from outside (input, output, errors), not how it’s achieved inside.

A good API contract is one that stays valid even if its internal implementation is completely rewritten. If an internal implementation change forces the contract to change too, the contract is probably leaking too many internal details to its public surface.

Summary

  • API contracts and data schemas differ from regular feature specs because many parties consume them in parallel — ambiguity here is far more expensive to fix after widespread use
  • OpenAPI makes REST API contracts executable: automatically validatable, generates stubs/clients, and documentation always in sync with the spec
  • JSON Schema defines data structures precisely — required, enum, and additionalProperties: false prevent ambiguity about which fields are mandatory and may change
  • Protobuf is more suitable for internal inter-service communication needing performance and cross-language strong typing, compared to REST+JSON which suits external clients better
  • Every status code and error response in an API spec should connect directly to the acceptance criteria already defined in the feature spec
  • The versioning policy must be stated explicitly as a constraint — which changes are safe to do directly and which must go through a new version
  • Avoid schemas too loose (all optional/any type), undefined error responses, and internal implementation details leaking into the public contract

Portfolio