05

Earn the First Service Boundary

The enterprise problem and today’s slice

Enterprise problem: ParcelFlow customers need orders to reserve stock reliably, but splitting code into networked services too early creates latency, deployment, and failure costs before those costs buy a visible benefit.

Whole-course context: The incoming evidence is a tenant-aware modular monolith with measured order and inventory behaviour; today decides whether Inventory has earned independent operation without changing the customer contract.

Today’s slice: We compare the monolith before and after extraction, implement Inventory behind Hono with its own store, and keep fake Payment inside the Order boundary.

End-of-day evidence: One acceptance suite passes against both implementations, a denied cross-tenant reservation stays denied, and traces prove Order never reads Inventory tables.

Still unsolved: Network contract evolution, asynchronous delivery events, and resilience during partial failure remain deliberately deferred.

Customer use cases

Customers can lose promised stock when a boundary changes semantics, so extraction is acceptable only when the same visible jobs retain positive and negative evidence.

Use case IDActorCustomer jobSuccess outcomeDenial or recovery evidence
D05-UC-01Tenant order operatorPlace an order whose stock is reserved exactly onceOrder ord-501 shows inventory_status=reserved with reservation res-501A request carrying another tenant's SKU returns TENANT_SCOPE_DENIED and creates no reservation
D05-UC-02Inventory ownerMove Inventory independently only after evidence justifies the costBefore/after acceptance runs have identical outcomes and the extracted service becomes the sole authoritative writerAn ambiguous extracted-service outcome fails closed and reconciles against that same authority; shadow-read and cutover evidence proves stores never accept competing writes

Actor-centred user stories

An extraction that satisfies architecture preferences but changes user outcomes is a regression, so stories bind the decision to observable behaviour.

Story IDUse case IDsUser storyObservable acceptance conditions
D05-US-01D05-UC-01As a tenant order operator, I want stock reserved within my tenant, so that an accepted order can be fulfilled without exposing another tenant's inventoryThe happy request returns the same order and reservation IDs before and after extraction; a foreign-tenant SKU is denied and both stores remain unchanged
D05-US-02D05-UC-02As an inventory owner, I want extraction gated by operational evidence, so that my team accepts network cost only when independent control repays itThe decision records all five evidence axes, the same suite passes twice, read-only shadow comparisons pass, and the cutover record proves exactly one authoritative writer

End-to-end product flows

A service cut can hide a semantic change behind a successful HTTP status, so each flow begins at the order screen and ends with inspectable state and trace evidence.

Flow IDUse case IDsPathTriggerNumbered stepsTerminal evidence
D05-FLOW-01D05-UC-01, D05-UC-02HappyOperator selects Place order for two units of sku-blue1. Order resolves tenant tn-a and validates the cart.<br>2. The InventoryPort targets either the in-process module or extracted Hono route.<br>3. Inventory atomically creates res-501 in its authoritative store.<br>4. Fake Payment records authorization without a real provider.<br>5. Order displays accepted status and the test runner compares both implementations.Actor operator-7, tenant tn-a, expected and observed reservation, environment, timestamp, trace ID, and two green acceptance-run IDs
D05-FLOW-02D05-UC-01, D05-UC-02RecoveryOrder receives an unavailable or timed-out response after the extracted Inventory service became authoritative1. Order sends only the published contract with one idempotency key, never a table query.<br>2. Because the result is ambiguous, Order records inventory_status=unknown, fails closed, and does not call fake Payment or the old module.<br>3. A recovery worker queries the extracted service by verified tenant and original idempotency key.<br>4. It attaches the existing reservation, or retries that same extracted authority only after an authoritative not-found result.<br>5. Product shows accepted or safely retryable with reconciliation evidence; any route reversal is a separately quiesced migration, never same-request failover.Zero or one res-501, no competing-store write, failure code, unchanged foreign-tenant stock, reconciliation result, cutover epoch, timestamp, trace ID, and immutable recovery-run ID

System design derived from the flows

Premature distribution turns function calls into unreliable network calls, so the design keeps one port and changes only the adapter after evidence supports extraction.

Use case IDEntry pointResponsible servicesAuthoritative storeFailure evidence
D05-UC-01POST /orders in Order Hono appOrder module, InventoryPort, Inventory module or extracted Inventory app, fake Payment moduleOrder database for orders; Inventory database for stock and reservationsStable TENANT_SCOPE_DENIED or INVENTORY_UNAVAILABLE, zero foreign rows, trace ID
D05-UC-02Inventory boundary review and cutover actionBoundary review job, read-only shadow comparator, strangler router, reconciliation worker, shared acceptance runnerBoundary evidence registry for decisions and runs; the active Inventory database remains the sole writerFailed shadow probe, ambiguous-call recovery ID, cutover epoch, single-writer proof, and unchanged acceptance assertions

Data model and ownership

Shared tables let one service bypass another service's invariants, so the extracted design gives Order opaque identifiers and leaves Inventory as the only writer of stock and reservations.

Generated-application database: Required in this slice — ParcelFlow Order and Inventory own separate application stores so tenant orders and stock reservations remain authoritative at their respective boundaries.

Record or entityStore and ownerPrimary keyForeign key or opaque referenceTenant keyMaterial constraintLifecycle and deletionUse case IDs
OrderOrder database, Order moduleorder_idreservation_id is an opaque Inventory referencetenant_id(tenant_id, idempotency_key) unique; status cannot become accepted without a reservation resultCreated on submit; retained for support; tenant erasure tombstones identity after financial retentionD05-UC-01
StockItemInventory database, Inventory ownerstock_item_idsku is a local catalogue referencetenant_id(tenant_id, sku) unique; available_quantity >= 0Created on catalogue activation; adjusted by audited operations; retired before deletionD05-UC-01, D05-UC-02
ReservationInventory database, Inventory ownerreservation_idorder_id is an opaque Order reference; stock_item_id local FKtenant_id(tenant_id, order_id, sku) unique; quantity positive; tenant must match StockItemCreated pending then confirmed or released; expired reservations retained through audit windowD05-UC-01, D05-UC-02
BoundaryDecisionEvidence registry, architecture ownerdecision_idacceptance_run_id references immutable test outputtenant_id or None — aggregate decision contains no tenant payloadAll five evidence axes, shadow comparison, cutover epoch, single-writer proof, and reconciliation rule required before extractDrafted, approved, superseded; retained with release evidence then archivedD05-UC-02

SOA, microservices, and extraction economics

Teams often equate service orientation with many deployables, which makes topology a goal and multiplies operating cost. Service-oriented architecture (SOA) is the broader style: capabilities are accessed through prescribed interfaces and constraints; the OASIS reference model deliberately does not require one technology or granularity. Microservices are a finer operational choice in that space: small boundaries intended to be independently deployable and operated. OASIS defines services and interfaces without fixing implementation technology.

General rule: start with modules and explicit interfaces; extract only when independent operation has measurable value. Simple example: two TypeScript modules behind InventoryPort are service-oriented even in one process. ParcelFlow example: Inventory becomes a separate Bun process only when its owner needs a different release or scaling path. Failure mode: extracting every noun adds deploys, dashboards, timeouts, and reconciliation without improving a customer outcome. Decision rule: the expected value across five axes must exceed network, consistency, and on-call cost.

Evidence axisEvidence that favours extractionEvidence that favours staying modular
OwnershipInventory has a stable accountable team and roadmapSame people change Order and Inventory together
ScaleReservation load or resource profile differs materiallyBoth scale together within headroom
SecurityInventory needs a narrower identity/network boundaryModule authorization already meets the risk
Release cadenceInventory deploys independently often enough to reduce lead timeChanges are coordinated in the same release
Failure isolationInventory faults must be contained and graceful degradation is designedA network split would worsen the dominant failure

Before, after, and the strangler transition

A big-bang cutover makes rollback expensive and hides whether the new boundary preserves behaviour, so a strangler transition compares the new path safely before transferring write authority exactly once.

Before, the modules share a process but not tables. During migration, the module remains the only writer while a replicated extracted store serves shadow reads whose results are compared but never returned to customers. Cutover briefly quiesces reservation writes, catches up the replica, verifies checksums and acceptance probes, records a cutover epoch, then makes the extracted Hono service and its separate database the sole authority. After cutover, unavailable or timed-out calls fail closed and reconcile there; returning to the module requires another quiesced data migration, not request-level fallback. The Hono Bun guide documents exporting a Hono app directly for Bun and testing its fetch handler with bun:test.

export type ReserveCommand = {
  orderId: string;
  sku: string;
  quantity: number;
  idempotencyKey: string;
};

export type VerifiedServiceContext = {
  workloadId: string;
  tenantId: string;
  scopes: readonly string[];
};

export interface InventoryPort {
  reserve(context: VerifiedServiceContext, command: ReserveCommand): Promise<{ reservationId: string }>;
}

The extracted app accepts tenant scope only from verified service authority. A trusted gateway may authenticate the user and mint a short-lived signed internal assertion, or Order may authenticate with workload identity and attach a signed tenant assertion; Inventory must verify signature, issuer, audience, expiry, workload identity, and inventory:reserve scope itself. A raw forwarding header or body field is never authority. Its repository is the only code allowed to query Inventory tables.

import { Hono } from "hono";

export const inventoryApp = new Hono().post("/v1/reservations", async (c) => {
  const context = await verifyInternalServiceRequest({
    authorization: c.req.header("authorization"),
    tenantAssertion: c.req.header("x-signed-tenant-assertion"),
    expectedAudience: "inventory",
  });
  if (!context?.scopes.includes("inventory:reserve")) {
    return c.json({ error: { code: "TENANT_SCOPE_DENIED" } }, 403);
  }
  const command = await c.req.json<ReserveCommand>();
  const result = await inventoryRepository.reserve({ ...command, tenantId: context.tenantId });
  return c.json(result, 201);
});
CREATE UNIQUE INDEX reservation_once
  ON inventory_reservations (tenant_id, order_id, sku);
REVOKE ALL ON inventory_stock, inventory_reservations FROM order_runtime;
inventory_route:
  mode: extracted
  ambiguous_outcome: fail-closed-and-reconcile-extracted-authority
  migration: shadow-read-then-quiesced-single-writer-cutover
  request_level_cross_store_fallback: forbidden
  forbidden: direct-table-access

One acceptance suite, two implementations

Different test suites can bless different semantics, so one provider-neutral suite must run unchanged against both adapters and include denial plus recovery.

import { describe, expect, test } from "bun:test";

export function inventoryAcceptance(makePort: () => InventoryPort) {
  describe("InventoryPort contract", () => {
    test("reserves once and rejects cross-tenant stock", async () => {
      const port = makePort();
      const tenantA = { workloadId: "order", tenantId: "tn-a", scopes: ["inventory:reserve"] };
      const tenantB = { workloadId: "order", tenantId: "tn-b", scopes: ["inventory:reserve"] };
      const command = { orderId: "ord-501", sku: "blue", quantity: 2, idempotencyKey: "idem-501" };
      expect(await port.reserve(tenantA, command)).toEqual({ reservationId: "res-501" });
      expect(await port.reserve(tenantA, command)).toEqual({ reservationId: "res-501" });
      await expect(port.reserve(tenantB, command)).rejects.toMatchObject({ code: "TENANT_SCOPE_DENIED" });
    });
  });
}
bun test tests/inventory-module.acceptance.test.ts
bun test tests/inventory-http.acceptance.test.ts
# expected terminal evidence: 2 files pass, identical snapshots, run IDs recorded

The failure mode is a green HTTP suite that never checks store effects. The decision rule is stronger: compare response, owned rows, denied foreign-tenant control, trace, and retry result before shifting traffic.

Key takeaways

Architecture labels can obscure whether a boundary pays for itself, so retain rules that connect topology to evidence.

  • SOA is a broad service/interface style; microservices are a finer independent-deployment choice.
  • A modular monolith with strict ports is the cheapest place to discover stable boundaries.
  • Extract Inventory only when ownership, scale, security, release cadence, or failure-isolation evidence outweighs distribution cost.
  • Preserve one acceptance contract, one Inventory owner, and zero cross-service table access.

Checklist

An extraction can look complete while leaving hidden coupling, so verify both customer behaviour and ownership before calling the cut finished.

  • [ ] Two use cases pass before and after with the same terminal evidence.
  • [ ] Inventory alone can read and write Inventory tables.
  • [ ] Tenant denial and idempotent retry leave zero duplicate or foreign rows.
  • [ ] All five extraction evidence axes have measured entries.
  • [ ] Ambiguous extracted-service outcomes fail closed and reconcile against that same authority.
  • [ ] Shadow mode is read-only, cutover is quiesced, and exactly one store accepts writes per epoch.
  • [ ] Inventory verifies signed service and tenant authority; it never trusts a raw forwarded header.
  • [ ] Fake Payment remains fake; no payment provider or credential is introduced.