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 axis | Evidence that favours extraction | Evidence that favours staying modular |
|---|---|---|
| Ownership | Inventory has a stable accountable team and roadmap | Same people change Order and Inventory together |
| Scale | Reservation load or resource profile differs materially | Both scale together within headroom |
| Security | Inventory needs a narrower identity/network boundary | Module authorization already meets the risk |
| Release cadence | Inventory deploys independently often enough to reduce lead time | Changes are coordinated in the same release |
| Failure isolation | Inventory faults must be contained and graceful degradation is designed | A 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.