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.

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.