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 ID | Actor | Customer job | Success outcome | Denial or recovery evidence |
|---|---|---|---|---|
| D05-UC-01 | Tenant order operator | Place an order whose stock is reserved exactly once | Order ord-501 shows inventory_status=reserved with reservation res-501 | A request carrying another tenant's SKU returns TENANT_SCOPE_DENIED and creates no reservation |
| D05-UC-02 | Inventory owner | Move Inventory independently only after evidence justifies the cost | Before/after acceptance runs have identical outcomes and the extracted service becomes the sole authoritative writer | An 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 ID | Use case IDs | User story | Observable acceptance conditions |
|---|---|---|---|
| D05-US-01 | D05-UC-01 | As a tenant order operator, I want stock reserved within my tenant, so that an accepted order can be fulfilled without exposing another tenant's inventory | The 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-02 | D05-UC-02 | As an inventory owner, I want extraction gated by operational evidence, so that my team accepts network cost only when independent control repays it | The 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 ID | Use case IDs | Path | Trigger | Numbered steps | Terminal evidence |
|---|---|---|---|---|---|
| D05-FLOW-01 | D05-UC-01, D05-UC-02 | Happy | Operator selects Place order for two units of sku-blue | 1. 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-02 | D05-UC-01, D05-UC-02 | Recovery | Order receives an unavailable or timed-out response after the extracted Inventory service became authoritative | 1. 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 ID | Entry point | Responsible services | Authoritative store | Failure evidence |
|---|---|---|---|---|
| D05-UC-01 | POST /orders in Order Hono app | Order module, InventoryPort, Inventory module or extracted Inventory app, fake Payment module | Order database for orders; Inventory database for stock and reservations | Stable TENANT_SCOPE_DENIED or INVENTORY_UNAVAILABLE, zero foreign rows, trace ID |
| D05-UC-02 | Inventory boundary review and cutover action | Boundary review job, read-only shadow comparator, strangler router, reconciliation worker, shared acceptance runner | Boundary evidence registry for decisions and runs; the active Inventory database remains the sole writer | Failed 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 entity | Store and owner | Primary key | Foreign key or opaque reference | Tenant key | Material constraint | Lifecycle and deletion | Use case IDs |
|---|---|---|---|---|---|---|---|
| Order | Order database, Order module | order_id | reservation_id is an opaque Inventory reference | tenant_id | (tenant_id, idempotency_key) unique; status cannot become accepted without a reservation result | Created on submit; retained for support; tenant erasure tombstones identity after financial retention | D05-UC-01 |
| StockItem | Inventory database, Inventory owner | stock_item_id | sku is a local catalogue reference | tenant_id | (tenant_id, sku) unique; available_quantity >= 0 | Created on catalogue activation; adjusted by audited operations; retired before deletion | D05-UC-01, D05-UC-02 |
| Reservation | Inventory database, Inventory owner | reservation_id | order_id is an opaque Order reference; stock_item_id local FK | tenant_id | (tenant_id, order_id, sku) unique; quantity positive; tenant must match StockItem | Created pending then confirmed or released; expired reservations retained through audit window | D05-UC-01, D05-UC-02 |
| BoundaryDecision | Evidence registry, architecture owner | decision_id | acceptance_run_id references immutable test output | tenant_id or None — aggregate decision contains no tenant payload | All five evidence axes, shadow comparison, cutover epoch, single-writer proof, and reconciliation rule required before extract | Drafted, approved, superseded; retained with release evidence then archived | D05-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 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.