The wire is already a contract, even when nobody wrote it down.
Imagine GET /profiles/:id. The server has a domain profile with an internal
note. A handler can return that object directly, and the browser can read displayName. The first version feels fast because no one had to name a response
type.
But the browser now depends on whatever the domain happened to serialize: the private note, field spelling, date representation, and missing-profile behavior. A second client may copy the browser’s guesses. A rename is now a coordination event, even though the type was never in a shared file.
If a caller can branch on it, display it, or retry it, it belongs in an explicit agreement.
Read the convenient starting pointTypeScript · domain serialization becomes the API
// An implicit endpoint serializes the domain object that happens to be handy.
// That silently makes internal fields part of the caller-facing API.
export function getProfileImplicit(id: string) {
if (id !== profile.id) return { status: 404, body: { message: 'Missing' } };
return { status: 200, body: { ...profile } };
} This function is not invalid JSON. It is an uncontrolled boundary. The private internalNote is now observable, and the generic message for a missing
profile gives callers no stable way to distinguish not-found from another failure.
A useful API contract has more than a happy-path type.
For the profile endpoint, write down the method and path, the success envelope, the error codes, the status meanings, and the compatibility rule. A schema file or generated client can help, but the decision is still an agreement owned by people and tested by systems.
Method and path
GET /profiles/:id tells both sides which resource and operation they are discussing.
Response shape
data holds public fields; meta carries request context and a schema
version.
Status and code
404 plus not_found gives transport and application meaning.
Compatibility policy
Optional additions can be safe; renames, type changes, and new cases need an explicit plan.
| Part | Promise | Why the caller needs it |
|---|---|---|
| Request | GET /profiles/:id | Construct the request without knowing server routing. |
| Success | 200 { data, meta } | Read public fields without depending on a domain object. |
| Not found | 404 { error: { code: "not_found" } } | Show an empty state instead of a generic crash. |
| Unknown failure | Safe fallback plus requestId | Fail closed while support can trace the request. |
Read the Go contractGo · separate transport structs and JSON tags
type profileData struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
JoinedAt string `json:"joinedAt"`
}
type profileResponse struct {
Data profileData `json:"data"`
Meta metadata `json:"meta"`
}
type metadata struct {
RequestID string `json:"requestId"`
SchemaVersion int `json:"schemaVersion"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"requestId"`
}
type errorResponse struct {
Error apiError `json:"error"`
}
func getProfile(id, requestID string) httpResponse {
if id != storedProfile.ID {
return httpResponse{
Status: 404,
Body: errorResponse{Error: apiError{Code: "not_found", Message: "Profile was not found.", RequestID: requestID}},
}
}
return httpResponse{
Status: 200,
Body: profileResponse{
Data: profileData{ID: storedProfile.ID, DisplayName: storedProfile.DisplayName, JoinedAt: storedProfile.JoinedAt},
Meta: metadata{RequestID: requestID, SchemaVersion: 1},
},
}
}
func getRateLimitedProfile(requestID string) httpResponse {
return httpResponse{
Status: 429,
Body: errorResponse{Error: apiError{Code: "rate_limited", Message: "Try again later.", RequestID: requestID}},
}
} The Go version does not marshal the private domain struct. Its transport structs name the fields that cross the wire, and tests pin the JSON keys. The same boundary can be written in TypeScript, Go, or another language without changing the agreement.
Compatibility is a decision, not a feeling.
Select a contract style and a provider change. Ask three questions: can the old consumer keep running, how would drift be detected, and what fallback is safe while deployments are out of step? The lab is a local model, but the choices are the ones a review must make.
Change the agreement and see who notices.
compatible
Server owns a named response envelope and a documented error union.
Client branches on status and stable fields instead of domain serialization.
Type and contract tests can expose a missing or changed field before deployment.
Watch for A type declaration is not documentation unless the server, clients, and tests use the same shape.
Read the explicit endpointTypeScript · named success and error envelopes
export const profileContract = {
method: 'GET',
path: '/profiles/:id',
success: '200 { data: { id, displayName, joinedAt }, meta: { requestId, schemaVersion } }',
failure: '400 | 404 | 429 { error: { code, message, requestId } }'
} as const;
function errorResponse(code: ApiErrorCode, message: string, requestId: string): ErrorResponse {
return { error: { code, message, requestId } };
}
export function getProfile(id: string, requestId: string): HttpResponse {
if (id !== profile.id) {
return { status: 404, body: errorResponse('not_found', 'Profile was not found.', requestId) };
}
return {
status: 200,
body: {
data: {
id: profile.id,
displayName: profile.displayName,
joinedAt: profile.joinedAt
},
meta: { requestId, schemaVersion: 1 }
}
};
}
export function getRateLimitedProfile(requestId: string): HttpResponse {
return { status: 429, body: errorResponse('rate_limited', 'Try again later.', requestId) };
} The explicit endpoint owns a stable surface. It is still possible to change it badly, but the change now has a name, a diff, and a place for a contract test. The client can treat an unknown error as unavailable instead of mistaking it for success.
Choose what the other side can safely assume.
Contract thinking becomes useful when a change is small enough to tempt a casual merge.
Separate domain data from transport data.
The mapping from domain profile to ProfileResponse is not ceremony for its own sake.
It is where the service chooses which fields are public, how they are named, and which metadata
belongs to the request rather than the profile.
Pair the mapping with contract tests. Provider tests pin the wire the service emits. Consumer tests pin the behavior the client needs. If you use a schema or code generator, put it in the checks that keep those two views from drifting.
The first version serializes whatever domain object is convenient.
// An implicit endpoint serializes the domain object that happens to be handy.
// That silently makes internal fields part of the caller-facing API.
export function getProfileImplicit(id: string) {
if (id !== profile.id) return { status: 404, body: { message: 'Missing' } };
return { status: 200, body: { ...profile } };
} // Serializing the domain struct makes its private fields part of the API.
func getProfileImplicit(id string) (int, any) {
if id != storedProfile.ID {
return 404, map[string]string{"message": "Missing"}
}
return 200, storedProfile
} - Resource paths and status meanings
- Public field names and omission rules
- Safe error codes and request tracing
- Which states it can render
- Fallback for newer responses
- Whether a retry is safe
- Success and failure examples
- Optional versus required fields
- Compatibility across deployment order
A component should depend on the client contract, not incidental JSON.
Build frontends?Every fetch in a component is a caller of someone’s contract.
Where it already is in your components
A profile card that fetches /profiles/7 and reads displayName is already a consumer. Whatever the server returned last week is the
contract that card depends on, whether or not anyone wrote it down.
When you have to own it
When the component chooses what to show for success and failure, own the client side of
the agreement. The textbook examples receive an API client whose return type names success
and failure, and they turn an error code into their own message plus the requestId instead of showing the server’s wording. The wild examples fetch
raw JSON and cast it into a guessed profile. That guess may compile while data.displayName is one level away, or while an error response is treated as a
profile.
Inject a typed API client, render the declared success shape, and map failure codes to client-owned text.
import { useEffect, useState } from 'react';
type ProfileResponse = {
data: { id: string; displayName: string; joinedAt: string };
meta: { requestId: string; schemaVersion: 1 };
};
type ErrorResponse = {
error: {
code: 'not_found' | 'invalid_request' | 'rate_limited';
message: string;
requestId: string;
};
};
type ApiResponse =
{ status: 200; body: ProfileResponse } | { status: 400 | 404 | 429; body: ErrorResponse };
type ProfileApi = { getProfile(id: string): Promise<ApiResponse> };
// The client owns what a reader sees; the server's message stays in logs.
function failureText(error: ErrorResponse['error']): string {
const text =
error.code === 'not_found' ? 'No profile found.' : 'The profile is temporarily unavailable.';
return `${text} Reference: ${error.requestId}`;
}
export function ProfileCard({ api, id }: { api: ProfileApi; id: string }) {
const [response, setResponse] = useState<ApiResponse | null>(null);
useEffect(() => {
let active = true;
void api.getProfile(id).then((next) => {
if (active) setResponse(next);
});
return () => {
active = false;
};
}, [api, id]);
if (!response) return <p>Loading…</p>;
if (response.status !== 200) return <p>{failureText(response.body.error)}</p>;
return (
<p>
<strong>{response.body.data.displayName}</strong> · joined {response.body.data.joinedAt}
</p>
);
}
The shape is only one part of the promise.
Document omission
Say whether a missing field means unknown, empty, not applicable, or a server bug.
Do not over-version
Prefer compatible additions and stable meanings before adding a version number to every change.
Unknown cases happen
Old clients can meet new error codes or fields. Define a safe fallback instead of assuming atomic deploys.
Generated is not guaranteed
Generation catches some drift at build time; it cannot coordinate release order or choose user-facing recovery.
Choose the lightest contract that prevents guessing.
An explicit type and a few examples are enough for a small internal endpoint with one consumer. A schema-first tool earns its cost when many consumers, languages, or teams need one machine-readable source. An implicit shape is a temporary implementation detail; if callers depend on it, it is already asking to be named.
Start by writing one success response, one not-found response, and one unexpected-failure fallback. Then write what an additive change may do, what a breaking change requires, and which test will tell you when the promise drifts.
Keep this questionAsk it before a handler returns a domain object.
Which fields, statuses, and error codes could a caller branch on, and where are they written down?
Every boundary lesson is a different part of the same agreement.
When another process depends on your response, make its assumptions visible before they become folklore.
Connections to follow nextRelated lessons
- Validation at the edge checks the incoming request before it enters the system.
- Domain model vs DTO keeps transport shape separate from business meaning.
- Errors across a boundary asks what survives when an internal failure becomes public JSON.
- Versioning and compatibility follows the same contract through time.
- Why
- Callers branch on the response, so its shape and meanings are already a promise.
- What
- A named success envelope, stable error codes, and a schema version, kept apart from the domain object.
- Constraint
- Provider and callers deploy independently; private fields never reach the wire.
- Fallback
- Unknown statuses and codes take a safe generic path that keeps the request ID.
- Reconsider when
- More consumers, languages, or teams need one machine-readable source.