Synchronous Contracts and API Evolution
The enterprise problem and today’s slice
Enterprise problem: ParcelFlow customers need an immediate order decision, but synchronous calls to Inventory and fake Payment can amplify malformed data, incompatible releases, and exhausted time budgets into visible checkout failure.
Whole-course context: The incoming evidence is an extracted Inventory boundary with identical acceptance behaviour and no cross-service table access; today makes its network contract evolvable and testable.
Today’s slice: We define Hono validation, stable success and error shapes, additive versioning, deadline propagation, and provider/consumer contract tests for Order→Inventory and fake Payment.
End-of-day evidence: Contract fixtures prove valid, denied, unknown-field, and timed-out paths with immutable test-run and trace IDs.
Still unsolved: Durable asynchronous publication, duplicate event delivery, and system-wide recovery from partial failure remain deferred.
Customer use cases
An immediate response is useful only if it is trustworthy, so the contract must distinguish accepted, denied, and uncertain outcomes without leaking tenant data.
| Use case ID | Actor | Customer job | Success outcome | Denial or recovery evidence |
|---|---|---|---|---|
| D06-UC-01 | Tenant order operator | Submit an order and receive a bounded reservation and fake-payment decision | Order ord-601 displays accepted with reservation and fake authorization references before the deadline | Invalid quantity, foreign tenant, or timeout yields a stable error code and no duplicate reservation or fake authorization |
| D06-UC-02 | API maintainer | Release an additive Inventory contract without breaking the deployed Order consumer | Old and new consumers accept the provider response; generated types and contract fixtures agree | Provider rejects a removed required field change; older consumer ignores an added response field and records compatibility evidence |
Actor-centred user stories
Compile-time agreement alone cannot prove runtime JSON or timing behaviour, so the stories require observed responses and owned-state checks.
| Story ID | Use case IDs | User story | Observable acceptance conditions |
|---|---|---|---|
| D06-US-01 | D06-UC-01 | As a tenant order operator, I want a prompt and unambiguous order decision, so that I do not resubmit an order whose state is merely unknown | Accepted requests expose reservation and fake authorization references; invalid or late requests expose a stable code, retryability flag, trace ID, and unchanged duplicate count |
| D06-US-02 | D06-UC-02 | As an API maintainer, I want additive evolution checked from both provider and consumer views, so that independently released components remain compatible | Provider fixtures validate every status; consumer parses a response containing an unknown field; a breaking fixture fails before release |
End-to-end product flows
Sequential dependencies consume one shared latency budget, so the product flow must pass the remaining deadline rather than give every downstream call a fresh timeout.
| Flow ID | Use case IDs | Path | Trigger | Numbered steps | Terminal evidence |
|---|---|---|---|---|---|
| D06-FLOW-01 | D06-UC-01, D06-UC-02 | Happy | Operator selects Place order with sku-blue, quantity 2 | 1. Order validates the public request and derives tenant identity.<br>2. Order computes remaining deadline and calls Inventory v1 with an idempotency key.<br>3. Inventory validates JSON, reserves stock, and returns an additive response.<br>4. Order invokes local fake Payment with the same order key.<br>5. Product displays accepted and stores contract revision plus trace. | Actor, tenant, resource, scope, precondition, expected and observed decision, environment, timestamp, trace ID, reservation ID, fake authorization ID, contract-run ID |
| D06-FLOW-02 | D06-UC-01, D06-UC-02 | Failure | Operator submits quantity 0, or Inventory exceeds the remaining deadline | 1. Order rejects invalid public input before downstream calls, or starts Inventory with a bounded abort signal.<br>2. Inventory returns the stable validation envelope, or Bun fetch aborts when the budget expires.<br>3. Order does not call fake Payment after a failed reservation.<br>4. Consumer maps the outcome to denied or uncertain rather than accepted.<br>5. Product displays the code and support trace. | VALIDATION_FAILED or INVENTORY_DEADLINE_EXCEEDED, HTTP status, retryable, trace ID, zero fake authorizations, unchanged duplicate count, immutable failure-run ID |
System design derived from the flows
Independent releases fail when schemas live only in developer memory, so a route-level schema must govern runtime validation, typed clients, documentation, and contract fixtures.
| Use case ID | Entry point | Responsible services | Authoritative store | Failure evidence |
|---|---|---|---|---|
| D06-UC-01 | POST /v1/orders | Order Hono API, Inventory Hono API, local fake Payment adapter | Order database for order and fake authorization; Inventory database for reservation | Stable error envelope, deadline trace, zero downstream write after denial |
| D06-UC-02 | Contract release pipeline and /openapi artifact | Inventory route schemas, Hono RPC/OpenAPI type generation, provider and consumer suites | Contract registry for immutable schema revision and evidence; domain state stays service-owned | Failed compatibility fixture, schema diff, consumer parse failure, run ID |
Data model and ownership
Contract metadata becomes dangerous when mistaken for domain authority, so schema evidence is stored separately while Order and Inventory retain their own business records.
Generated-application database: Required in this slice — ParcelFlow Order and Inventory databases persist tenant-scoped order, fake authorization, and reservation outcomes; the contract registry stores only release evidence.
| Record or entity | Store and owner | Primary key | Foreign key or opaque reference | Tenant key | Material constraint | Lifecycle and deletion | Use case IDs |
|---|---|---|---|---|---|---|---|
| Order | Order database, Order service | order_id | reservation_id opaque Inventory reference | tenant_id | (tenant_id, idempotency_key) unique; accepted requires confirmed reservation and fake authorization | Created on submit; cancelled or completed; retained per order policy then tenant-erased | D06-UC-01 |
| FakeAuthorization | Order database, fake Payment adapter | authorization_id | order_id local FK | tenant_id | (tenant_id, order_id) unique; amount equals order total; explicitly non-financial | Created after reservation only; voided on order failure; deleted with order after support retention | D06-UC-01 |
| Reservation | Inventory database, Inventory service | reservation_id | order_id opaque Order reference | tenant_id | (tenant_id, order_id, sku) unique; quantity positive and bounded by available stock | Created confirmed; released on cancellation or expiry; retained through audit window | D06-UC-01, D06-UC-02 |
| ContractRevision | Contract registry, API maintainers | contract_revision_id | source_digest immutable repository reference | None — schema contains no tenant payload | Semantic version, generated artifact digest, and compatibility policy immutable | Published after gates; superseded not mutated; retained while supported then archived | D06-UC-02 |
| ContractRun | Contract registry, release pipeline | contract_run_id | contract_revision_id local FK | tenant_id only for synthetic tenant fixture | Provider and consumer result plus environment and timestamp required | Append-only release evidence; fixture payloads expire; summary retained | D06-UC-01, D06-UC-02 |
Contract first: validation, RPC, and a stable envelope
Unvalidated JSON moves defects into business logic and creates inconsistent errors, so validation must occur at the route and return one durable envelope. Hono provides a thin validator that can be combined with third-party validators, while its RPC client can infer validated input and JSON output types; these are complementary, not substitutes for runtime checks. See Hono's official validation and RPC guides.
General rule: validate at every trust boundary and publish a small semantic contract. Simple example: reject quantity: 0 before any repository call. ParcelFlow example: Order and Inventory share generated route types, but Inventory still validates the bytes it receives. Failure mode: TypeScript types disappear at runtime and an independently deployed caller sends malformed JSON. Decision rule: if a value can cross a process or release boundary, validate it at runtime and test the serialized form.
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const reserveInput = z.object({
orderId: z.string().min(1),
sku: z.string().min(1),
quantity: z.number().int().positive(),
idempotencyKey: z.string().min(8),
});
type ApiError = {
error: { code: string; message: string; retryable: boolean; traceId: string; details?: Record<string, string> };
};
export const inventoryV1 = new Hono().post(
"/v1/reservations",
zValidator("json", reserveInput, (result, c) => result.success ? undefined : c.json<ApiError>({
error: { code: "VALIDATION_FAILED", message: "Reservation input is invalid", retryable: false, traceId: c.get("traceId") },
}, 400)),
async (c) => c.json(await reserve(c.get("tenantId"), c.req.valid("json")), 201),
);
export type InventoryV1 = typeof inventoryV1;
For teams needing a language-neutral artifact, Hono's official Zod OpenAPI example shows schemas driving request validation and OpenAPI generation. Keep the envelope stable: status gives transport class; code drives program behaviour; message is safe for humans; retryable prevents blind retry; traceId supports investigation. Do not branch on message text.
error:
code: INVENTORY_DEADLINE_EXCEEDED
message: Inventory did not answer within the order deadline
retryable: true
traceId: tr-06-failure-17
Additive evolution and unknown fields
Breaking a provider and consumer at the same time defeats independent release, so evolve v1 additively and create v2 only when semantics cannot remain compatible.
General rule: add optional fields and new endpoints; do not rename, remove, narrow, or reinterpret existing fields in place. Simple example: adding optional warehouseZone must not stop an older consumer reading reservationId. ParcelFlow example: Inventory may add expiresAt while Order v1 ignores it. Failure mode: a strict consumer rejects every object containing a field it did not compile against. Decision rule: consumers extract fields they understand; providers keep old required behaviour for the published support window.
type ReservationV1 = { reservationId: string; status: "reserved" };
export function parseReservationV1(value: unknown): ReservationV1 {
const parsed = z.object({
reservationId: z.string(),
status: z.literal("reserved"),
}).passthrough().parse(value);
return { reservationId: parsed.reservationId, status: parsed.status };
}
INSERT INTO contract_revisions
(contract_revision_id, api_name, semantic_version, source_digest, compatibility_mode)
VALUES
('cr-106', 'inventory', '1.4.0', 'sha256:fixture', 'additive-v1');
Deadlines and AbortSignal propagation
Fresh timeouts at every hop can exceed the customer's total patience and continue work after Order has abandoned it, so downstream calls receive only the remaining budget. Bun's official fetch documentation uses AbortSignal.timeout() for bounded requests and AbortController for cancellation. Bun fetch timeout and cancellation.
export async function reserveWithDeadline(input: unknown, deadlineEpochMs: number) {
const remainingMs = Math.max(1, deadlineEpochMs - Date.now() - 20);
const response = await fetch("http://inventory.internal/v1/reservations", {
method: "POST",
headers: { "content-type": "application/json", "x-deadline-epoch-ms": String(deadlineEpochMs) },
body: JSON.stringify(input),
signal: AbortSignal.timeout(remainingMs),
});
if (!response.ok) throw await response.json();
return parseReservationV1(await response.json());
}
The failure mode is treating an abort as proof Inventory did nothing; the response was not observed, but the operation may have committed. The decision rule is to retry only with the same idempotency key or query authoritative status.
Provider and consumer contract tests
One-sided tests miss mismatched assumptions, so providers prove every documented response and consumers prove the exact shapes and tolerance they rely on. Hono documents direct request testing through app.request, and its testing helper produces a typed client for chained routes. Hono testing guide and testing helper.
import { expect, test } from "bun:test";
test("provider rejects invalid quantity with stable envelope", async () => {
const response = await inventoryV1.request("/v1/reservations", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ orderId: "ord-601", sku: "blue", quantity: 0, idempotencyKey: "idem-0601" }),
});
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ error: { code: "VALIDATION_FAILED", retryable: false } });
});
test("old consumer ignores additive provider fields", () => {
expect(parseReservationV1({ reservationId: "res-601", status: "reserved", expiresAt: "2026-08-10T12:00:00Z" }))
.toEqual({ reservationId: "res-601", status: "reserved" });
});
bun test tests/contracts/inventory-provider.test.ts tests/contracts/order-consumer.test.ts
# expected terminal evidence: 4 pass, 0 fail, contract_run_id=ctr-0606, trace fixtures attached
Key takeaways
Synchronous APIs fail at both data and time boundaries, so treat schemas, errors, and deadlines as one contract.
- Runtime validation complements Hono RPC types; it is not replaced by them.
- Stable machine codes and retryability matter more than mutable message prose.
- Additive v1 changes and unknown-field tolerance support independent releases.
- A propagated deadline bounds the whole customer action; an abort does not prove no commit.
- Provider and consumer suites must exercise serialized happy, denied, compatibility, and timeout paths.
Checklist
An API can compile while remaining operationally incompatible, so release only after wire-level evidence exists.
- [ ] Request and response schemas validate at each process boundary.
- [ ] Every error uses the stable envelope and safe machine code.
- [ ] Older consumer passes with an added response field.
- [ ] Breaking field removal or reinterpretation fails the compatibility gate.
- [ ] Remaining deadline reaches Inventory through
AbortSignal. - [ ] Fake Payment is local, deterministic, and never called after reservation failure.
- [ ] Terminal output records contract-run and trace IDs.