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
-
Explicit beats implicit (states, errors, versioning).
-
Backward compatible by default (additive changes).
-
Idempotent where users retry.
-
Partial failure is a first-class outcome.
-
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
requestIdfor 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
| Code | Meaning | Retryable | HTTP Status |
|---|---|---|---|
VALIDATION_FAILED | Client sent invalid data | No | 400 |
AUTH_REQUIRED | User not logged in | No | 401 |
FORBIDDEN | Authenticated but not authorized | No | 403 |
NOT_FOUND | Resource doesn't exist | No | 404 |
CONFLICT | State conflict (e.g., duplicate) | No | 409 |
RATE_LIMITED | Too many requests | Yes (with backoff) | 429 |
INTERNAL | Server error | Yes | 500 |
SERVICE_UNAVAILABLE | Upstream/dependency down | Yes | 503 |
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
Sunsetheader 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
}
}
nextCursoris opaque; client never parses it. Never assume cursor format—backend may change encoding.- Last page:
nextCursor: nullorhasMore: 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
| Strategy | Pros | Cons | Use When |
|---|---|---|---|
Offset (page, limit) | Simple, random access | Skips/duplicates when data changes; slow at high offsets | Small lists, admin UIs |
| Cursor (opaque token) | Stable, efficient; no skips | No random access; cursor tied to query | Infinite scroll, feeds |
Keyset (e.g., id > lastId) | Deterministic, index-friendly | Exposes internals; complex with multi-column sort | High-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
nullsemantics -
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
itemsalways present; optional fields may benull. warningsexplain what's missing and which items are affected.meta.degradedsignals 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:
processing→confirmedonly after payment gateway success.failed→paymentallows retry;cartallows editing.- UI never shows
confirmedwithout backend confirmation. - Backend rejects
submitPaymentif 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 Type | Who Runs | What It Catches |
|---|---|---|
| Consumer contract | Frontend CI | "We expect this shape" |
| Provider contract | Backend CI | "We still satisfy consumers" |
| Schema diff | Both | Breaking schema changes |
| E2E | Integration | Full 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
- Schema lint on every PR (OpenAPI/GraphQL valid).
- Schema diff against main—fail on breaking changes.
- Provider verification against published pacts (if using Pact).
- 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
-
Define an error taxonomy for your app (10–15 codes max).
-
Choose cursor pagination for one list and specify cursor semantics.
-
Pick a flow and define a UI state machine + matching backend invariants.
-
Decide whether you need a BFF and justify.
-
Add schema validation or contract tests to your CI pipeline.
-
Design a partial-failure response for an endpoint that aggregates 2+ upstream services.
-
Add a versioning strategy (URL, header, or both) for one API and document the deprecation policy.