07. API Contract Design — make the hallway explicit before traffic starts¶
~13 min read. Loose contracts feel fast, then one missing field burns a sprint.
Built on the ELI5 in 00-eli5.md. The hallway — the agreed passage between rooms — decides what shape may pass and how future changes stay safe.
1) A contract is executable agreement, not decorative documentation¶
An API contract is the written version of the hallway.
It tells callers what may enter, what returns, and what breaks.
See. When the hallway is vague, every team invents its own map.
Suppose checkout sends amount = 499.
Payments reads it as rupees.
Ledger reads it as paise.
One order becomes either ₹499 or ₹4.99.
Same code path, completely different money.
Simple, no?
A useful contract names five things clearly.
Request fields, response fields, required values, allowed errors, versioning rules.
That is already half the design battle.
┌────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Web client │───▶ │ API contract │───▶ │ Order service│
└────────────┘ │ types + rules │ └──────────────┘
┌────────────┐ │ errors + limits │
│ Mobile app │───▶ │ examples │
└────────────┘ └──────────────────┘
POST /orders.
Web sends 12 fields, iOS sends 11, Android sends 10.
If the contract marks 7 fields as required, all three teams align early.
If not, production becomes the meeting room.
Code-level example in TypeScript:
export type CreateOrderRequest = {
orderId: string;
amountPaise: number;
currency: 'INR' | 'USD';
customerId: string;
};
2) OpenAPI, protobuf, and JSON Schema solve different boundary problems¶
People sometimes ask, "Which one is best?" Wrong question. Ask instead, "Which boundary am I controlling?" The boundary decides the tool. OpenAPI fits HTTP APIs well. It describes paths, query params, headers, status codes, and examples. Frontend teams, QA, and SDK generators all benefit quickly.
paths:
/orders:
post:
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
message CreateOrderRequest {
string order_id = 1;
int64 amount_paise = 2;
string currency = 3;
string customer_id = 4;
}
{
"type": "object",
"required": ["email", "age"],
"properties": {
"email": { "type": "string", "format": "email" },
"age": { "type": "integer", "minimum": 18 }
}
}
┌──────────────┬──────────────────────┬─────────────────────────┐
│ Tool │ Strongest fit │ Watch out for │
├──────────────┼──────────────────────┼─────────────────────────┤
│ OpenAPI │ Public HTTP surface │ Spec drift from code │
│ Protobuf │ Internal RPC │ Field-number mistakes │
│ JSON Schema │ JSON bodies/events │ Partial validator usage │
└──────────────┴──────────────────────┴─────────────────────────┘
3) Contract-first versus code-first is really about team coordination speed¶
Contract-first means the spec arrives before the handler implementation. Code-first means code annotations or types generate the spec later. Both can work. The better choice depends on how many teams must move together. Contract-first shines when backend, frontend, QA, and partner teams move in parallel. You publish the hallway early. Mocks, stubs, and examples appear before business logic finishes. See the flow.
Spec written
│
├──▶ Mock server for frontend
├──▶ SDK generation for partners
└──▶ Server skeleton for backend
@PostMapping("/orders")
OrderResponse create(@Valid @RequestBody CreateOrderRequest req) {
return service.create(req);
}
const CreateOrderSchema = z.object({
orderId: z.string().uuid(),
amountPaise: z.number().int().positive(),
currency: z.enum(['INR', 'USD']),
});
4) Validation belongs at the edge, and compatibility belongs in every release¶
Schema validation should run as early as possible. Reject bad shapes before business logic starts improvising. The edge is cheaper than the core. See.
Request
│
▼
Schema validation ──invalid──▶ 400 with field errors
│
▼
Business validation ──rule fail──▶ 409 or 422
│
▼
Handler + persistence
const valid = ajv.validate(createOrderJsonSchema, payload);
if (!valid) {
throw new BadRequestError(ajv.errors);
}
public record CreateOrderRequest(
@NotBlank String orderId,
@Min(1) long amountPaise,
@NotBlank String currency
) {}
amountPaise to amount or make an optional field required;
- change string to number silently or reuse protobuf field number 3 for new meaning.
Bad protobuf evolution:
Later, never do this:
Those bytes already mean email to older consumers.
The wire remembers even when developers forget.
Simple, no?
Reserve the removed number instead.
Worked example.
Suppose 40% of Android users stay one version behind for two weeks.
If you suddenly require couponCode, four of ten apps may fail checkout.
That is not a neat refactor.
That is a revenue leak.
So what to do?
Deprecate first, measure adoption, support overlap, then remove carefully.
Contract safety is a release habit, not a one-time slide.
Where this lives in the wild¶
- A Stripe API engineer evolves payment fields carefully so old merchant SDKs still serialize requests safely.
- A Swiggy mobile-platform engineer depends on stable OpenAPI specs so Android and iOS releases can move independently.
- A Google infrastructure engineer uses protobuf definitions to keep many internal services compatible during rolling deploys.
- A Razorpay backend engineer validates webhook payloads against JSON Schema before ledger code touches money movement.
- A Postman product engineer generates mocks and examples from OpenAPI so developer onboarding stays consistent.
Pause and recall¶
- When does OpenAPI fit better than protobuf?
- Why is contract-first useful when multiple teams move together?
- Which schema changes are usually backward compatible at code level?
- Why should validation happen before business logic?
Interview Q&A¶
Why contract-first not code-first for a shared partner API?¶
Because partner teams need the hallway before your service finishes implementation. Mocks, SDKs, reviews, and negative tests can start earlier. Common wrong answer to avoid: "Contract-first is always better because it looks more formal."
Why protobuf not JSON over HTTP for internal high-volume RPC?¶
Because protobuf gives smaller payloads, generated stubs, and disciplined compatibility rules. That matters when many services roll independently all day. Common wrong answer to avoid: "Binary is automatically better for every boundary."
Why schema validation not handwritten if checks scattered everywhere?¶
Because one schema keeps rules central, testable, and reusable across handlers. Scattered checks drift fast and miss edge cases. Common wrong answer to avoid: "Manual checks are flexible, so structure is unnecessary."
Why add optional fields not rename required ones immediately?¶
Because old clients ignore extra optional data more safely than missing renamed fields. Compatibility is about rollout reality, not only clean code. Common wrong answer to avoid: "Clients should update quickly, so breaking changes are acceptable."
Apply now (5 min)¶
Exercise.
Design POST /shipments for an ecommerce system.
Write 5 request fields, 3 validation rules, 2 response fields, and 2 error codes.
Then express the same request once in OpenAPI and once in protobuf.
Sketch from memory.
Draw the edge-validation pipeline, then list four safe and unsafe contract changes side by side.
Say aloud which change breaks old clients first and why.
Bridge. A strong hallway tells services what may pass, but behavior still needs legal moves, guards, and clear transitions. → 08-state-machines-and-workflows.md