06

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 IDActorCustomer jobSuccess outcomeDenial or recovery evidence
D06-UC-01Tenant order operatorSubmit an order and receive a bounded reservation and fake-payment decisionOrder ord-601 displays accepted with reservation and fake authorization references before the deadlineInvalid quantity, foreign tenant, or timeout yields a stable error code and no duplicate reservation or fake authorization
D06-UC-02API maintainerRelease an additive Inventory contract without breaking the deployed Order consumerOld and new consumers accept the provider response; generated types and contract fixtures agreeProvider 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 IDUse case IDsUser storyObservable acceptance conditions
D06-US-01D06-UC-01As a tenant order operator, I want a prompt and unambiguous order decision, so that I do not resubmit an order whose state is merely unknownAccepted 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-02D06-UC-02As an API maintainer, I want additive evolution checked from both provider and consumer views, so that independently released components remain compatibleProvider 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 IDUse case IDsPathTriggerNumbered stepsTerminal evidence
D06-FLOW-01D06-UC-01, D06-UC-02HappyOperator selects Place order with sku-blue, quantity 21. 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-02D06-UC-01, D06-UC-02FailureOperator submits quantity 0, or Inventory exceeds the remaining deadline1. 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 IDEntry pointResponsible servicesAuthoritative storeFailure evidence
D06-UC-01POST /v1/ordersOrder Hono API, Inventory Hono API, local fake Payment adapterOrder database for order and fake authorization; Inventory database for reservationStable error envelope, deadline trace, zero downstream write after denial
D06-UC-02Contract release pipeline and /openapi artifactInventory route schemas, Hono RPC/OpenAPI type generation, provider and consumer suitesContract registry for immutable schema revision and evidence; domain state stays service-ownedFailed 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 entityStore and ownerPrimary keyForeign key or opaque referenceTenant keyMaterial constraintLifecycle and deletionUse case IDs
OrderOrder database, Order serviceorder_idreservation_id opaque Inventory referencetenant_id(tenant_id, idempotency_key) unique; accepted requires confirmed reservation and fake authorizationCreated on submit; cancelled or completed; retained per order policy then tenant-erasedD06-UC-01
FakeAuthorizationOrder database, fake Payment adapterauthorization_idorder_id local FKtenant_id(tenant_id, order_id) unique; amount equals order total; explicitly non-financialCreated after reservation only; voided on order failure; deleted with order after support retentionD06-UC-01
ReservationInventory database, Inventory servicereservation_idorder_id opaque Order referencetenant_id(tenant_id, order_id, sku) unique; quantity positive and bounded by available stockCreated confirmed; released on cancellation or expiry; retained through audit windowD06-UC-01, D06-UC-02
ContractRevisionContract registry, API maintainerscontract_revision_idsource_digest immutable repository referenceNone — schema contains no tenant payloadSemantic version, generated artifact digest, and compatibility policy immutablePublished after gates; superseded not mutated; retained while supported then archivedD06-UC-02
ContractRunContract registry, release pipelinecontract_run_idcontract_revision_id local FKtenant_id only for synthetic tenant fixtureProvider and consumer result plus environment and timestamp requiredAppend-only release evidence; fixture payloads expire; summary retainedD06-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.