← Concepts & practices
Pattern Boundaries and contracts

API contracts

Make the agreement visible.

A profile endpoint is not “some JSON over HTTP.” It is an agreement between a provider and every caller: which method and path exist, what success means, which fields are present, how failure is identified, and what may change without coordination. Make that promise explicit before a domain object accidentally becomes a public API.

The judgment to keep

A contract is the smallest agreement both sides can safely depend on. Name the wire shape and status meanings, keep private fields out, and decide how a caller behaves when the other side is newer than it is.

TypeScriptGo One profile endpoint · provider and consumer in view
Start with the caller

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
profiles.ts · implicit serialization
// 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.

Name the promise

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.

01 / Address

Method and path

GET /profiles/:id tells both sides which resource and operation they are discussing.

02 / Success

Response shape

data holds public fields; meta carries request context and a schema version.

03 / Failure

Status and code

404 plus not_found gives transport and application meaning.

04 / Change

Compatibility policy

Optional additions can be safe; renames, type changes, and new cases need an explicit plan.

The profile endpoint agreement
PartPromiseWhy the caller needs it
RequestGET /profiles/:idConstruct the request without knowing server routing.
Success200 { data, meta }Read public fields without depending on a domain object.
Not found404 { error: { code: "not_found" } }Show an empty state instead of a generic crash.
Unknown failureSafe fallback plus requestIdFail closed while support can trace the request.
Read the Go contractGo · separate transport structs and JSON tags
profiles.go · transport contract
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.

Change the provider

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.

Profile API

Change the agreement and see who notices.

Runs a local contract model
Owned request/response types Add optional timezone
01Provider changes the named contract
02Contract test compares the promised envelope
03Compatible additions remain optional
04Breaking changes need a rollout plan
Compatibility

compatible

Provider

Server owns a named response envelope and a documented error union.

Consumer

Client branches on status and stable fields instead of domain serialization.

Detection

Type and contract tests can expose a missing or changed field before deployment.

An explicit contract makes ownership and compatibility questions reviewable.

Watch for A type declaration is not documentation unless the server, clients, and tests use the same shape.

The controls change a local model; they do not call a live API or generate a client.
Read the explicit endpointTypeScript · named success and error envelopes
profiles.ts · explicit contract
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.

Practice the review

Choose what the other side can safely assume.

Contract thinking becomes useful when a change is small enough to tempt a casual merge.

A profile endpoint is called by a browser and a mobile app. What should they share?
The server adds an optional field. What is the safest default?
A generated client exists. What responsibility remains?
A client receives a status it does not recognize. What should it do?
Feedback stays on this page; it is not saved.
Put it in a service

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.

TypeScriptReading
profiles.ts
// 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 } };
}
GoAlongside
profiles.go
// 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
}
Provider owns
  • Resource paths and status meanings
  • Public field names and omission rules
  • Safe error codes and request tracing
Consumer owns
  • Which states it can render
  • Fallback for newer responses
  • Whether a retry is safe
Both test
  • Success and failure examples
  • Optional versus required fields
  • Compatibility across deployment order
Recognize it in UI code

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.

ReactAlready in your code
textbook.tsx · typed API client
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>
	);
}
Keep the contract honest

The shape is only one part of the promise.

01

Document omission

Say whether a missing field means unknown, empty, not applicable, or a server bug.

02

Do not over-version

Prefer compatible additions and stable meanings before adding a version number to every change.

03

Unknown cases happen

Old clients can meet new error codes or fields. Define a safe fallback instead of assuming atomic deploys.

04

Generated is not guaranteed

Generation catches some drift at build time; it cannot coordinate release order or choose user-facing recovery.

Make the call

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?

Take the idea with you

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
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.