Skip to main content

SENIORS SHIP CONTRACTS, NOT ENDPOINTS

The fastest teams are not the ones with the best framework.

They're the ones with the cleanest contracts between:

  • UI ↔︎ API

  • service ↔︎ service

  • sync ↔︎ async boundaries

This chapter is a practical contract playbook.


CONTRACT PRINCIPLES

  1. Explicit beats implicit (states, errors, versioning).

  2. Backward compatible by default (additive changes).

  3. Idempotent where users retry.

  4. Partial failure is a first-class outcome.

  5. Contracts encode semantics, not implementation.

Examples in Practice

Explicit beats implicit: Instead of status: 200 with an empty body meaning "no results," return { data: [], meta: { total: 0 } }. The UI knows the difference between "empty list" and "request failed."

Backward compatible by default: Adding displayName to a user object is safe. Removing email breaks clients. Add fields; deprecate before removing.

Idempotent where users retry: Payment submission uses idempotencyKey. Duplicate requests with the same key return the same result—no double charge.

Partial failure is first-class: A product list API that enriches with inventory and reviews returns items even if inventory service is down; warnings signals what's missing.

Contracts encode semantics: retryable: true tells the UI to show "Retry" instead of "Contact support." The contract encodes what to do, not just what happened.

In practice: Before shipping a new endpoint, ask: "What does the UI do when X happens?" If the answer is "we didn't think about that," the contract is incomplete.

Senior rule:

Principles without examples are slogans. Every principle should map to a concrete decision in your codebase.


API SHAPE GUIDELINES

Responses

  • stable envelope: { data, error, meta } (or a consistent variant)

  • include requestId for support/on-call correlation

Complete Response Envelope Example

{
"data": {
"id": "usr_abc123",
"email": "jane@example.com",
"displayName": "Jane Doe"
},
"error": null,
"meta": {
"requestId": "req_7f8a9b2c3d4e",
"timestamp": "2025-03-10T14:32:00Z",
"version": "2.1"
}
}

Error response:

{
"data": null,
"error": {
"code": "VALIDATION_FAILED",
"message": "Invalid input",
"details": [
{ "field": "email", "reason": "Invalid format" }
],
"retryable": false
},
"meta": {
"requestId": "req_7f8a9b2c3d4e",
"timestamp": "2025-03-10T14:32:00Z"
}
}

Error taxonomy

Use typed errors; don't rely on free-form strings.

Example:

  • code: VALIDATION_FAILED | AUTH_REQUIRED | FORBIDDEN | NOT_FOUND | CONFLICT | RATE_LIMITED | INTERNAL

  • details: field-level validation info

  • retryable: boolean

CodeMeaningRetryableHTTP Status
VALIDATION_FAILEDClient sent invalid dataNo400
AUTH_REQUIREDUser not logged inNo401
FORBIDDENAuthenticated but not authorizedNo403
NOT_FOUNDResource doesn't existNo404
CONFLICTState conflict (e.g., duplicate)No409
RATE_LIMITEDToo many requestsYes (with backoff)429
INTERNALServer errorYes500
SERVICE_UNAVAILABLEUpstream/dependency downYes503

Extending the taxonomy: Add domain-specific codes (e.g., CART_EMPTY, PAYMENT_DECLINED) but keep the core set small. Prefer details for context over new top-level codes. Document each code: when it's used, whether it's retryable, and what the UI should do.

Senior rule:

If the UI can't decide "retry vs prompt user vs escalate" you didn't define errors.


VERSIONING (HOW TO EVOLVE WITHOUT BREAKING)

Preferred approach:

  • keep URLs stable

  • evolve response schema additively

  • deprecate fields with a long window

When you need a true break:

  • new route or explicit version: /v2/...

  • support both versions until clients roll

Mobile reality:

  • assume old clients live for weeks/months

Header-Based Versioning

When URL versioning is too coarse:

Accept: application/vnd.myapi.v2+json
Accept-Version: 2
  • Client declares desired version; server returns compatible response. Fallback: if client omits header, server returns latest or default version.
  • Keeps URLs stable; versioning is in headers.
  • Useful for mobile apps that can't upgrade instantly.

Sunset Headers

When deprecating a version:

Deprecation: true
Sunset: Sat, 01 Jun 2025 00:00:00 GMT
Link: </v2/users>; rel="successor-version"
  • Clients can log warnings and plan migration. Parse Sunset header and surface in monitoring dashboards.
  • Sunset date gives a hard deadline.

Consumer-Driven Contract Testing

Clients define what they expect; backend tests verify compatibility.

  • Mobile team publishes: "We expect GET /users/{id} to return { id, email, displayName }."
  • Backend changes that break this fail CI before deploy.
  • Prevents "we didn't know anyone used that field."

Version support window: Document how long old versions are supported (e.g., "v1 supported until 2025-06-01"). Enforce sunset headers and monitor client version adoption before deprecating. Use analytics to know when old clients drop below threshold (e.g., <1% traffic).

Senior rule:

Versioning is a contract. Document it. Enforce it in CI.


PAGINATION CONTRACTS

Use cursor-based pagination for large lists.

Contract:

  • request: limit, cursor

  • response: items, nextCursor

Cursor rules:

  • opaque

  • tied to filters/sort (include hash)

  • stable tie-breaker

Avoid:

  • offset pagination at scale

  • "page=3" without deterministic ordering

Request/Response Example

// Request
GET /products?limit=20&cursor=eyJpZCI6IjEyMyIsInRzIjoxNzA5ODQwMDAwfQ

// Response
{
"data": {
"items": [
{ "id": "124", "name": "Widget", "price": 9.99 }
],
"nextCursor": "eyJpZCI6IjE0NCIsInRzIjoxNzA5ODQwMDAxfQ",
"hasMore": true
},
"error": null,
"meta": {
"requestId": "req_xyz",
"totalEstimate": null
}
}
  • nextCursor is opaque; client never parses it. Never assume cursor format—backend may change encoding.
  • Last page: nextCursor: null or hasMore: false.

Cursor encoding: Base64-encode { id, timestamp } or { id, sortKey } so the cursor is stable and tied to the query. Include a hash of filter params so ?category=shoes and ?category=hats never share cursors.

Pagination Strategy Tradeoffs

StrategyProsConsUse When
Offset (page, limit)Simple, random accessSkips/duplicates when data changes; slow at high offsetsSmall lists, admin UIs
Cursor (opaque token)Stable, efficient; no skipsNo random access; cursor tied to queryInfinite scroll, feeds
Keyset (e.g., id > lastId)Deterministic, index-friendlyExposes internals; complex with multi-column sortHigh-throughput APIs

Limit bounds: Enforce limit min/max (e.g., 1–100). Default to 20. Prevents abuse and keeps response sizes predictable.

Senior rule:

Cursor pagination is the default for user-facing lists. Offset is a legacy escape hatch.


PARTIAL FAILURE PATTERNS

Partial failures happen when you aggregate:

  • multiple upstream calls

  • optional enrichments

Patterns:

  • return core items + warnings[]

  • mark fields as optional with explicit null semantics

  • provide a missingDependencies[] signal if applicable

UI rule:

The UI must be able to render a degraded-but-true state.

Degraded Response Example

{
"data": {
"items": [
{
"id": "prod_1",
"name": "Widget",
"price": 9.99,
"inStock": true,
"reviewCount": null,
"avgRating": null
},
{
"id": "prod_2",
"name": "Gadget",
"price": 19.99,
"inStock": null,
"reviewCount": 42,
"avgRating": 4.2
}
],
"warnings": [
{
"code": "INVENTORY_UNAVAILABLE",
"message": "Stock info temporarily unavailable",
"affectedIds": ["prod_2"]
}
],
"missingDependencies": ["inventory-service"]
},
"error": null,
"meta": {
"requestId": "req_abc",
"degraded": true
}
}
  • Core items always present; optional fields may be null.
  • warnings explain what's missing and which items are affected.
  • meta.degraded signals the UI to show a banner: "Some data may be outdated."

Caching: When degraded: true, avoid caching the response aggressively—stale data may be worse than no data. Or cache with short TTL and retry soon.

UI handling: For null optional fields, show placeholder ("—" or skeleton) instead of hiding the row. For warnings, show a dismissible banner. Never block rendering of core data.


BFF VS DIRECT-TO-SERVICE

BFF (Backend for Frontend)

Pros:

  • UI-optimized responses

  • hides microservice complexity

  • centralizes auth/session

Cons:

  • can become a god-layer

Senior default:

  • BFF for complex products and multiple clients (web/mobile).

Direct-to-service can work for small systems, but becomes expensive as complexity grows.

Architecture Diagram

                    ┌─────────────────┐
│ Web Client │
└────────┬────────┘

┌────────▼────────┐
│ Web BFF │ ← UI-shaped responses
│ (Node/Go) │ auth, session
└────────┬────────┘

┌────────────────────┼────────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ User Service │ │ Product Svc │ │ Order Service │
└───────────────┘ └───────────────┘ └────────────────┘

When to Split BFFs

  • Single BFF: One product, one client type (e.g., web only).
  • Split by client: Web BFF vs Mobile BFF when payloads and flows diverge.
  • Split by domain: Checkout BFF vs Discovery BFF when teams and scaling differ.

Anti-Patterns

  • Thin proxy: BFF that just forwards requests—adds latency without value.
  • God BFF: One BFF for everything—becomes a bottleneck; split by client or domain.
  • BFF calling BFF: Chain of BFFs—flatten or consolidate.
  • Leaky abstraction: BFF exposes internal service errors or IDs—UI shouldn't know about order-service-503.

Decision Checklist

  • Do you have 2+ client types (web, mobile, partner API)? → Consider BFF per client.
  • Do you aggregate 3+ services for a single screen? → BFF reduces round-trips.
  • Is auth/session logic duplicated across clients? → BFF centralizes it.
  • Is your system a single service with one SPA? → Direct-to-service may suffice.

Senior rule:

BFF exists to optimize for the client. If it doesn't reduce round-trips or simplify the UI contract, question it.


SCHEMA-FIRST (OPENAPI / GRAPHQL)

Schema-first gives you:

  • contract reviews

  • client generation

  • compatibility checks

Guideline:

  • use schema as the canonical artifact

  • treat schema changes like code changes (reviews + changelog)

Type Generation

OpenAPI → TypeScript:

npx openapi-typescript https://api.example.com/openapi.json -o src/api/types.ts
  • Frontend gets typed fetch/axios; compile-time errors on contract drift.

GraphQL:

npx graphql-codegen --config codegen.yml
  • Generates hooks, types, and fragments from schema + operations.

Breaking Change Detection in CI

# Example: openapi-diff in CI
- name: Check API compatibility
run: |
npx openapi-diff base.json head.json --fail-on-diff
  • Fails if responses remove fields, change types, or break required fields.
  • Additive changes (new optional fields) pass.

Changelog discipline: Every schema PR includes a changelog entry: "Added optional preferences.theme to User." Breaking changes require migration notes and version bump.

Breaking vs additive: Removing a field, changing type (string→number), making optional→required = breaking. Adding optional field, adding enum value (if clients handle unknown) = additive. When in doubt, treat as breaking.

Senior rule:

Schema is source of truth. Generated types are a byproduct. Never hand-write types that duplicate the schema.


UI STATES AS STATE MACHINES (CONTRACT MEETS UX)

Most bugs are "impossible states" made possible.

Define UI state machines for flows:

  • checkout

  • upload

  • auth

Then map them to backend invariants:

  • backend guarantees transitions are valid

  • UI never invents success

Checkout Flow State Machine Example

                    ┌──────────────┐
│ idle │
└──────┬───────┘
│ startCheckout

┌──────────────┐
│ cart │◄─────── addItem
└──────┬───────┘
│ proceedToPayment

┌──────────────┐
│ payment │
└──────┬───────┘
│ submitPayment

┌────────────┴────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ processing │ │ failed │
└──────┬───────┘ └──────┬───────┘
│ success │ retry
▼ │
┌──────────────┐ │
│ confirmed │ └──────────► payment
└──────────────┘

States: idle | cart | payment | processing | confirmed | failed

Transitions (events): startCheckout, addItem, proceedToPayment, submitPayment, success, retry, cancel

Backend invariants:

  • processingconfirmed only after payment gateway success.
  • failedpayment allows retry; cart allows editing.
  • UI never shows confirmed without backend confirmation.
  • Backend rejects submitPayment if cart is empty or expired.

Implementation: Use XState, Robot, or a simple useReducer with explicit state enum. Never use booleans like isLoading && !error—they create impossible combinations.

Other flows to model: Upload (idle → uploading → success/failed), auth (unauthenticated → loading → authenticated/error), search (idle → searching → results/empty/error). Each has distinct states and transitions; model them explicitly. Document transitions in a table or diagram; share with backend so invariants align.

Senior rule:

Every UI flow has a state machine. If you can't draw it, you'll ship impossible states.


CONTRACT TESTING IN CI

Pact and Consumer-Driven Contracts

Pact (and similar tools) let consumers define expectations; providers verify they satisfy them.

Consumer (Frontend)                    Provider (Backend)
│ │
│ 1. Define expected request/response │
│ (e.g., GET /users/123) │
│ │
│ 2. Publish pact file │
│ ─────────────────────────────────► │
│ │
│ 3. Provider verifies
│ against pact
│ │
│ 4. CI fails if
│ provider breaks contract
  • Consumer tests mock the provider; pact file captures the contract.
  • Provider tests replay pact interactions against real implementation.
  • Breaking the contract fails provider CI before deploy.

Schema Validation in Pipelines

# Validate OpenAPI schema on every PR
- name: Validate OpenAPI
run: |
npx @redocly/cli lint openapi.yaml

# Diff against main branch
- name: API compatibility check
run: |
npx openapi-diff openapi-main.yaml openapi-pr.yaml --fail-on-incompatible
  • Linting catches invalid schemas.
  • Diff catches breaking changes (removed fields, changed types).

Workflow: Schema lives in repo; PRs that touch it run validation. Merge to main triggers provider contract tests. Consumer teams run their pact tests against the latest provider pacts.

Tooling options: Pact (consumer-driven), Spring Cloud Contract, Postman (schema validation), Spectral (OpenAPI linting). Pick one and integrate early—retrofitting is painful.

When to skip: Single-team monolith with one frontend? Schema lint + manual review may suffice. Multi-team, multiple consumers? Contract testing pays off quickly.

Contract Test Types

Test TypeWho RunsWhat It Catches
Consumer contractFrontend CI"We expect this shape"
Provider contractBackend CI"We still satisfy consumers"
Schema diffBothBreaking schema changes
E2EIntegrationFull stack; slow, last line of defense

Layering: Contract tests catch interface drift fast. E2E catches integration bugs. Both matter; contract tests run in seconds, E2E in minutes. Run contract tests on every PR; E2E on main or before release.

Minimum Viable Contract CI

  1. Schema lint on every PR (OpenAPI/GraphQL valid).
  2. Schema diff against main—fail on breaking changes.
  3. Provider verification against published pacts (if using Pact).
  4. Changelog entry for any schema change (manual or automated).

Rollout: Start with schema lint + diff. Add Pact/provider verification when you have 2+ consumers. Don't boil the ocean—incremental adoption beats big-bang.

Senior rule:

Contract tests run in CI. If a breaking change ships, the pipeline failed, not the process.


EXERCISES

  1. Define an error taxonomy for your app (10–15 codes max).

  2. Choose cursor pagination for one list and specify cursor semantics.

  3. Pick a flow and define a UI state machine + matching backend invariants.

  4. Decide whether you need a BFF and justify.

  5. Add schema validation or contract tests to your CI pipeline.

  6. Design a partial-failure response for an endpoint that aggregates 2+ upstream services.

  7. Add a versioning strategy (URL, header, or both) for one API and document the deprecation policy.


🏁 END — FRONTEND–BACKEND CONTRACTS