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.

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.