A 200 response can still be wrong.
The order page loads order-42 from an order service. The consumer needs a success
response with an order ID, a known status, a non-negative integer total in cents, and a currency
code. A successful transport status is necessary, but it is not the whole agreement.
We will hold that consumer need fixed while the provider changes one fixture at a time: an aligned payload, a renamed ID field, a string total, a serializer that turns the total into text only on the wire, and a temporary 503. The goal is not to test every endpoint. It is to learn what this check establishes and what it leaves for another test.
Consumer and provider can deploy independently.
- Required body
orderId,status,totalCents, andcurrency.- Allowed status
pending,ready, orcancelled.- Unknown fields
- Allowed, because the consumer does not rely on them yet.
- Evidence
- A precise field failure, a passing contract, or a transport failure.
From symptom to evidence.
Each step leaves an artifact for the next one. If the expected observation does not appear, stop and revise the question instead of widening the fix.
- 01 Reproduce
Fix one request and one response.
Use
order-42and the smallest provider fixture that shows the problem. Keep the consumer expectation visible.Leave with A repeatable boundary case and the behavior to preserve. - 02 Specify
Write the consumer contract.
List required fields, allowed values, units, and whether extra fields are tolerated. Keep this close to the consumer’s real use.
Leave with A fixture and assertions that could fail for a plausible drift. - 03 Verify provider
Run the provider against the agreement.
Feed the provider response to the same contract check. A renamed field should identify
body.orderId, not merely say “request failed.”Leave with A passing provider check or a field-level mismatch to send to the owner. - 04 Cross the wire
Exercise serialization and decoding.
Encode the response and decode it as the consumer would. This catches drift that exists only on the wire, such as a serializer that writes the total as text, while keeping network timing out of the claim.
Leave with Evidence that the contract survives the boundary, or a smaller integration failure. - 05 Interpret and replay
Classify the first failure.
A 503 is availability evidence; a 200 with a string total is payload drift. Turn the mismatch into a regression fixture and change one owner at a time.
Leave with A checked behavior, a named uncertainty, and the next investigation if needed.
Run the boundary, then read what it proved.
The lab runs the displayed TypeScript implementation. Select a provider fixture and a check scope, then inspect the output and the observed wire. The result is behavior evidence from this bounded case, not a claim about a real network or every provider endpoint.
Can the consumer still trust the response?
Keep the consumer contract fixed. Change the provider fixture or the boundary checked.
The provider sends id instead of orderId.
Choose a provider fixture and check scope, then run the check.
When the expected failure does not appearCheck the instrument before the fix
If a renamed field passes, the contract may not be asserting the field the consumer uses, or the fixture may not be the response that crossed the boundary. Inspect the exact payload and the assertion path. If the contract passes but the page still fails, move to consumer mapping, rendering, or a different integration condition rather than weakening this check without evidence.
Hold the contract steady. Change the language.
The examples check the same order response. The browser lab runs TypeScript; the panes show how each language preserves the field-level contract.
Define the fixture and assertions
export function providerResponse(version: ProviderVersion): ProviderResponse {
switch (version) {
case 'aligned':
return { status: 200, body: { ...consumerContract } };
case 'renamed-field':
return {
status: 200,
body: { id: consumerContract.orderId, status: 'ready', totalCents: 1299, currency: 'USD' }
};
case 'wrong-type':
return {
status: 200,
body: { orderId: 'order-42', status: 'ready', totalCents: '1299', currency: 'USD' }
};
case 'serializer-drift':
// In memory the total is still a number; the provider's serializer writes it as text.
return {
status: 200,
body: {
...consumerContract,
toJSON: () => ({ ...consumerContract, totalCents: String(consumerContract.totalCents) })
}
};
case 'server-error':
return { status: 503, body: { error: 'temporarily unavailable' } };
}
} func ProviderResponseFor(version ProviderVersion) ProviderResponse {
switch version {
case Aligned:
return ProviderResponse{200, map[string]any{"orderId": "order-42", "status": "ready", "totalCents": 1299, "currency": "USD"}}
case RenamedField:
return ProviderResponse{200, map[string]any{"id": "order-42", "status": "ready", "totalCents": 1299, "currency": "USD"}}
case WrongType:
return ProviderResponse{200, map[string]any{"orderId": "order-42", "status": "ready", "totalCents": "1299", "currency": "USD"}}
case SerializerDrift:
// In memory the total is still an int; the ",string" option writes it as text.
return ProviderResponse{200, providerOrder{OrderID: "order-42", Status: "ready", TotalCents: 1299, Currency: "USD"}}
case ServerError:
return ProviderResponse{503, map[string]any{"error": "temporarily unavailable"}}
default:
return ProviderResponse{500, map[string]any{"error": "unknown fixture"}}
}
}
// providerOrder is the provider's own response model.
type providerOrder struct {
OrderID string `json:"orderId"`
Status string `json:"status"`
TotalCents int `json:"totalCents,string"`
Currency string `json:"currency"`
} The contract says what the consumer relies on and allows extra fields it does not yet use.
Verify provider and wire behavior
export function validateOrderResponse(response: ProviderResponse): string[] {
const failures: string[] = [];
if (response.status !== 200) {
failures.push(`status: expected 200, received ${response.status}`);
return failures;
}
if (!isRecord(response.body)) return ['body: expected an object'];
if (typeof response.body.orderId !== 'string') failures.push('body.orderId: expected a string');
if (!['pending', 'ready', 'cancelled'].includes(String(response.body.status))) {
failures.push('body.status: expected pending, ready, or cancelled');
}
if (
typeof response.body.totalCents !== 'number' ||
!Number.isInteger(response.body.totalCents) ||
response.body.totalCents < 0
) {
failures.push('body.totalCents: expected a non-negative integer');
}
if (typeof response.body.currency !== 'string') failures.push('body.currency: expected a string');
return failures;
}
// Checks the provider's response object as the provider built it, before it is serialized.
export function runConsumerContract(version: ProviderVersion): ContractRun {
const response = providerResponse(version);
const wire = JSON.stringify(response);
const failures = validateOrderResponse(response);
return { ok: failures.length === 0, failures, response, wire };
}
// Checks what the consumer decodes after the response crosses the JSON boundary.
export function runIntegrationCheck(version: ProviderVersion): ContractRun {
const response = providerResponse(version);
const wire = JSON.stringify(response);
const received = JSON.parse(wire) as ProviderResponse;
const failures = validateOrderResponse(received);
return { ok: failures.length === 0, failures, response: received, wire };
}
export function runCheck(scope: CheckScope, version: ProviderVersion): ContractRun {
return scope === 'consumer' ? runConsumerContract(version) : runIntegrationCheck(version);
} // VerifyOrderContract checks the provider's response object as the provider built it.
func VerifyOrderContract(provider ProviderResponse) ContractResult {
result := ContractResult{OK: true, Response: provider}
fail := func(message string) {
result.OK = false
result.Failures = append(result.Failures, message)
}
if provider.Status != 200 {
fail(fmt.Sprintf("status: expected 200, received %d", provider.Status))
return result
}
body, ok := fieldsOf(provider.Body)
if !ok {
fail("body: expected an object")
return result
}
if _, ok := body["orderId"].(string); !ok {
fail("body.orderId: expected a string")
}
status, ok := body["status"].(string)
if !ok || (status != "pending" && status != "ready" && status != "cancelled") {
fail("body.status: expected pending, ready, or cancelled")
}
if !nonNegativeInteger(body["totalCents"]) {
fail("body.totalCents: expected a non-negative integer")
}
if _, ok := body["currency"].(string); !ok {
fail("body.currency: expected a string")
}
return result
}
// RunIntegrationCheck checks what the consumer decodes after the response crosses the JSON boundary.
func RunIntegrationCheck(version ProviderVersion) ContractResult {
provider := ProviderResponseFor(version)
wire, err := json.Marshal(provider)
if err != nil {
return ContractResult{OK: false, Failures: []string{"transport: could not encode response"}, Response: provider}
}
var received ProviderResponse
if err := json.Unmarshal(wire, &received); err != nil {
return ContractResult{OK: false, Failures: []string{"transport: could not decode response"}, Response: provider, Wire: wire}
}
result := VerifyOrderContract(received)
result.Wire = wire
return result
}
func fieldsOf(body any) (map[string]any, bool) {
switch value := body.(type) {
case map[string]any:
return value, true
case providerOrder:
return map[string]any{"orderId": value.OrderID, "status": value.Status, "totalCents": value.TotalCents, "currency": value.Currency}, true
default:
return nil, false
}
}
func nonNegativeInteger(value any) bool {
switch number := value.(type) {
case int:
return number >= 0
case float64: // what encoding/json decodes a JSON number into
return number >= 0 && number == float64(int(number))
default:
return false
}
} Provider verification and a local round trip answer related but distinct questions: payload agreement and serialization-path agreement. The serializer fixture passes the first and fails the second.
See the consumer call siteOnly use a response after checking it
export function loadOrder(version: ProviderVersion, scope: CheckScope = 'integration') {
const result = runCheck(scope, version);
if (!result.ok)
return { ok: false as const, error: result.failures[0], failures: result.failures };
return { ok: true as const, order: result.response.body as OrderSummary };
} func LoadOrder(version ProviderVersion) (OrderSummary, error) {
result := RunIntegrationCheck(version)
if !result.OK {
return OrderSummary{}, fmt.Errorf("contract failed: %s", result.Failures[0])
}
var decoded struct {
Body OrderSummary `json:"body"`
}
if err := json.Unmarshal(result.Wire, &decoded); err != nil {
return OrderSummary{}, err
}
return decoded.Body, nil
} The caller receives a typed order only after the boundary check passes.
Copy the complete examplesStandard library only
These files are complete and copyable, and use only the standard library.
export type OrderStatus = 'pending' | 'ready' | 'cancelled';
export type ProviderVersion =
'aligned' | 'renamed-field' | 'wrong-type' | 'serializer-drift' | 'server-error';
export type CheckScope = 'consumer' | 'integration';
export type OrderSummary = {
orderId: string;
status: OrderStatus;
totalCents: number;
currency: string;
};
export type ProviderResponse = { status: number; body: unknown };
export type ContractRun = {
ok: boolean;
failures: string[];
response: ProviderResponse;
wire: string;
};
export const consumerContract: OrderSummary = {
orderId: 'order-42',
status: 'ready',
totalCents: 1299,
currency: 'USD'
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function providerResponse(version: ProviderVersion): ProviderResponse {
switch (version) {
case 'aligned':
return { status: 200, body: { ...consumerContract } };
case 'renamed-field':
return {
status: 200,
body: { id: consumerContract.orderId, status: 'ready', totalCents: 1299, currency: 'USD' }
};
case 'wrong-type':
return {
status: 200,
body: { orderId: 'order-42', status: 'ready', totalCents: '1299', currency: 'USD' }
};
case 'serializer-drift':
// In memory the total is still a number; the provider's serializer writes it as text.
return {
status: 200,
body: {
...consumerContract,
toJSON: () => ({ ...consumerContract, totalCents: String(consumerContract.totalCents) })
}
};
case 'server-error':
return { status: 503, body: { error: 'temporarily unavailable' } };
}
}
export function validateOrderResponse(response: ProviderResponse): string[] {
const failures: string[] = [];
if (response.status !== 200) {
failures.push(`status: expected 200, received ${response.status}`);
return failures;
}
if (!isRecord(response.body)) return ['body: expected an object'];
if (typeof response.body.orderId !== 'string') failures.push('body.orderId: expected a string');
if (!['pending', 'ready', 'cancelled'].includes(String(response.body.status))) {
failures.push('body.status: expected pending, ready, or cancelled');
}
if (
typeof response.body.totalCents !== 'number' ||
!Number.isInteger(response.body.totalCents) ||
response.body.totalCents < 0
) {
failures.push('body.totalCents: expected a non-negative integer');
}
if (typeof response.body.currency !== 'string') failures.push('body.currency: expected a string');
return failures;
}
// Checks the provider's response object as the provider built it, before it is serialized.
export function runConsumerContract(version: ProviderVersion): ContractRun {
const response = providerResponse(version);
const wire = JSON.stringify(response);
const failures = validateOrderResponse(response);
return { ok: failures.length === 0, failures, response, wire };
}
// Checks what the consumer decodes after the response crosses the JSON boundary.
export function runIntegrationCheck(version: ProviderVersion): ContractRun {
const response = providerResponse(version);
const wire = JSON.stringify(response);
const received = JSON.parse(wire) as ProviderResponse;
const failures = validateOrderResponse(received);
return { ok: failures.length === 0, failures, response: received, wire };
}
export function runCheck(scope: CheckScope, version: ProviderVersion): ContractRun {
return scope === 'consumer' ? runConsumerContract(version) : runIntegrationCheck(version);
}
export function loadOrder(version: ProviderVersion, scope: CheckScope = 'integration') {
const result = runCheck(scope, version);
if (!result.ok)
return { ok: false as const, error: result.failures[0], failures: result.failures };
return { ok: true as const, order: result.response.body as OrderSummary };
}
export function example() {
return loadOrder('aligned');
}
console.log(example());
package main
import (
"encoding/json"
"fmt"
)
type ProviderVersion string
const (
Aligned ProviderVersion = "aligned"
RenamedField ProviderVersion = "renamed-field"
WrongType ProviderVersion = "wrong-type"
SerializerDrift ProviderVersion = "serializer-drift"
ServerError ProviderVersion = "server-error"
)
type OrderSummary struct {
OrderID string `json:"orderId"`
Status string `json:"status"`
TotalCents int `json:"totalCents"`
Currency string `json:"currency"`
}
// ProviderResponse is the response as the provider built it, before serialization.
type ProviderResponse struct {
Status int `json:"status"`
Body any `json:"body"`
}
type ContractResult struct {
OK bool
Failures []string
Response ProviderResponse
Wire []byte
}
func ProviderResponseFor(version ProviderVersion) ProviderResponse {
switch version {
case Aligned:
return ProviderResponse{200, map[string]any{"orderId": "order-42", "status": "ready", "totalCents": 1299, "currency": "USD"}}
case RenamedField:
return ProviderResponse{200, map[string]any{"id": "order-42", "status": "ready", "totalCents": 1299, "currency": "USD"}}
case WrongType:
return ProviderResponse{200, map[string]any{"orderId": "order-42", "status": "ready", "totalCents": "1299", "currency": "USD"}}
case SerializerDrift:
// In memory the total is still an int; the ",string" option writes it as text.
return ProviderResponse{200, providerOrder{OrderID: "order-42", Status: "ready", TotalCents: 1299, Currency: "USD"}}
case ServerError:
return ProviderResponse{503, map[string]any{"error": "temporarily unavailable"}}
default:
return ProviderResponse{500, map[string]any{"error": "unknown fixture"}}
}
}
// providerOrder is the provider's own response model.
type providerOrder struct {
OrderID string `json:"orderId"`
Status string `json:"status"`
TotalCents int `json:"totalCents,string"`
Currency string `json:"currency"`
}
// VerifyOrderContract checks the provider's response object as the provider built it.
func VerifyOrderContract(provider ProviderResponse) ContractResult {
result := ContractResult{OK: true, Response: provider}
fail := func(message string) {
result.OK = false
result.Failures = append(result.Failures, message)
}
if provider.Status != 200 {
fail(fmt.Sprintf("status: expected 200, received %d", provider.Status))
return result
}
body, ok := fieldsOf(provider.Body)
if !ok {
fail("body: expected an object")
return result
}
if _, ok := body["orderId"].(string); !ok {
fail("body.orderId: expected a string")
}
status, ok := body["status"].(string)
if !ok || (status != "pending" && status != "ready" && status != "cancelled") {
fail("body.status: expected pending, ready, or cancelled")
}
if !nonNegativeInteger(body["totalCents"]) {
fail("body.totalCents: expected a non-negative integer")
}
if _, ok := body["currency"].(string); !ok {
fail("body.currency: expected a string")
}
return result
}
// RunIntegrationCheck checks what the consumer decodes after the response crosses the JSON boundary.
func RunIntegrationCheck(version ProviderVersion) ContractResult {
provider := ProviderResponseFor(version)
wire, err := json.Marshal(provider)
if err != nil {
return ContractResult{OK: false, Failures: []string{"transport: could not encode response"}, Response: provider}
}
var received ProviderResponse
if err := json.Unmarshal(wire, &received); err != nil {
return ContractResult{OK: false, Failures: []string{"transport: could not decode response"}, Response: provider, Wire: wire}
}
result := VerifyOrderContract(received)
result.Wire = wire
return result
}
func fieldsOf(body any) (map[string]any, bool) {
switch value := body.(type) {
case map[string]any:
return value, true
case providerOrder:
return map[string]any{"orderId": value.OrderID, "status": value.Status, "totalCents": value.TotalCents, "currency": value.Currency}, true
default:
return nil, false
}
}
func nonNegativeInteger(value any) bool {
switch number := value.(type) {
case int:
return number >= 0
case float64: // what encoding/json decodes a JSON number into
return number >= 0 && number == float64(int(number))
default:
return false
}
}
func LoadOrder(version ProviderVersion) (OrderSummary, error) {
result := RunIntegrationCheck(version)
if !result.OK {
return OrderSummary{}, fmt.Errorf("contract failed: %s", result.Failures[0])
}
var decoded struct {
Body OrderSummary `json:"body"`
}
if err := json.Unmarshal(result.Wire, &decoded); err != nil {
return OrderSummary{}, err
}
return decoded.Body, nil
}
func main() {
order, err := LoadOrder(Aligned)
if err != nil {
panic(err)
}
fmt.Printf("%s: %d cents\n", order.OrderID, order.TotalCents)
}
TypeScriptnode --experimental-strip-types orders.ts
Gogo run orders.go
A contract is a boundary check, not a whole system test.
This procedure establishes that the chosen consumer fields and meanings match the chosen provider fixture and, in the integration mode, survive local JSON encoding and decoding. It does not establish network availability over time, authentication, retry behavior, database correctness, latency, or every business rule.
Keep those questions separate. Add a failure-path test for a retry contract, an end-to-end test for wiring and authentication, or a domain test for totals. A contract failure should make the next evidence smaller, not invite a broad rewrite.
Build UIs?See where this shows up in your components.
The frontend can own the consumer side.
A fetch wrapper can decode the response once and expose a checked order to components. A page test can then verify loading, empty, and error rendering separately. The component should not silently rename or coerce fields just to make a drifting endpoint look compatible.
Respond to evidence, not just symptoms.
Use the field-level failure as your clue. Choose the next piece of evidence that can distinguish provider drift from a consumer mistake.
The contract check fails: body.totalCents is a string, not an integer.
The provider team says the endpoint is returning 200. What is the most useful next move?
Make the next replay cheaper.
A passing contract check is useful only when someone can tell what it covered. Leave a note that says why the check exists, what it checks, what it cannot see, what happens when it fails, and when to revisit it.
- Why
- The order page and the order service deploy independently and share one payload, so each side’s unit tests can pass while the pair disagrees.
- What
- A consumer contract for
GET /orders/order-42, checked against the provider fixture and again after a JSON round trip, with a field path in every failure. - Constraint
- It covers only the fields the consumer reads and the local wire path. Availability, auth, retries, latency, and persistence need tests of their own.
- Fallback
- A field-level failure goes to the side that owns it as a regression fixture. A 503 is availability, not drift. A needed rename gets a compatibility window.
- Reconsider when
- The consumer starts reading a new field, the provider changes its serializer or units, or a second consumer comes to depend on the same response.
A procedure note to adapt to your own boundary. Nothing here is saved to an account.
Explore more concepts & practices →