The service boundary had no independent life
I audited P6X4's service map against change, scale, authority, recovery, and transaction boundaries—and found several boxes that existed only on the diagram.
P6X4 began with a diagram I was proud to show and a change I was reluctant to make.
The diagram contained separate services for cataloguing a document, generating its rendered form, delivering it, notifying the requester, recording the audit trail, and updating search. The change was smaller: let a document title contain an optional subtitle and show it in the generated file.
The field crossed four repositories, three event schemas, two deployment sequences, and a projection rebuild. Nothing about the subtitle needed independent scale, a different security authority, or an isolated failure policy. I had distributed a single idea because the boxes looked like sensible nouns.
That realization did not prove that services were wrong. It proved that my existing boundaries had not earned their cost.
A boundary needs a life on both sides
I had been evaluating services from the inside. Each repository looked coherent when viewed alone. The renderer rendered. The catalogue stored metadata. The delivery process delivered. Their names were clear, their test suites were small, and every deployment produced a satisfying row of green checks.
The user intent did not respect those neat interiors. A person requested one document. To fulfil that request, the system needed a consistent title, template revision, delivery policy, and audit record. The fields changed together because they described one operation, not because the implementation happened to be untidy.
I wrote a stronger test for a proposed service boundary:
- Can either side change meaningfully without coordinating with the other?
- Does either side need materially different scale?
- Does it enforce an independent authority or security policy?
- Can it fail and recover on a different schedule?
- Is eventual consistency acceptable across the boundary?
- Is there a stable contract a caller can understand without sharing private state?
A boundary did not need to pass every test. It needed a concrete reason stronger than “this code has a different noun.”
Change independence was observable
The first question sounded subjective until I used repository history as evidence.
I created a small analyzer that normalized commit timestamps, extracted files changed together, and mapped each file to a service and feature area. Merge commits, generated files, lockfiles, formatting sweeps, and dependency updates were excluded because they created misleading clusters. The remaining history was synthetic—I had built P6X4 specifically as an architecture laboratory—but it represented the kinds of changes the application actually required.
The result was not an architecture generator. It was a set of prompts.
The catalogue and renderer changed together for almost every document feature. Their supposedly independent schemas shared version assumptions. Delivery changed independently more often because adapter policies, credentials, and retry behavior evolved without changing the document model. Search changed in response to document features, but it could lag and rebuild from durable source records.
Those patterns gave the diagram a different shape. Rendering looked like a module within the document capability. Delivery still behaved like a durable external-effect boundary. Search looked like a replaceable projection rather than an authority.
Co-change alone could not settle the decision. Two well-separated components can change together during a broad feature, and tangled components can remain unchanged for months. But repeated coordination was evidence that “independent deployment” existed only as a deployment mechanism, not as a property of the work.
Scale had to be specific
“It might need to scale” had justified several splits. The phrase hid three different questions.
Throughput asked how many operations a component performed. Resource shape asked whether those operations wanted CPU, memory, network bandwidth, or a special runtime. Isolation asked whether a burst in one activity should be able to starve another.
Document rendering genuinely had a different resource shape from request handling. It consumed CPU and memory in bursts. That did not automatically require a separately versioned domain service. I could run the same application artifact in a worker process, expose the renderer through an internal package boundary, and assign that process its own concurrency and memory limits.
This distinction mattered. A runtime process boundary could protect resources without creating a separately governed data model and network contract. I had bundled deployment, ownership, scaling, and modularity into one decision called “service.” They were separate decisions.
Delivery retained stronger independence. It waited on unreliable external adapters, needed per-adapter rate limits, and could continue after the web process restarted. Its work had durable identity and explicit receipts. Even there, the worker did not need a private copy of authoritative document state. It consumed an immutable delivery request emitted by the document transaction.
Authority was more important than ownership labels
Each P6X4 service initially “owned” a database. That phrase sounded disciplined and explained very little.
The catalogue stored the current document title. The renderer stored a snapshot of the title used in the latest file. Search stored a normalized copy for matching. Delivery stored the title again for message templates. Four databases contained similar fields, and every repository called its copy owned.
I classified each copy instead:
- Authority: the value against which a new user decision was validated.
- Snapshot: an immutable record of what a completed operation observed.
- Projection: derived state that could be rebuilt from authoritative records.
- Cache: disposable state with an explicit freshness policy.
- Accidental authority: a copy callers treated as current because it was convenient.
The catalogue was authoritative for editable document metadata. A render manifest was a snapshot tied to one document revision. Search was a projection. The delivery copy should have been part of a stable request snapshot, but its service had quietly acquired update endpoints and become an accidental second authority.
That was the dangerous boundary. It did not merely cost network calls. It made it unclear which decision could safely overwrite which value.
Recovery revealed the real units of work
I next disabled each component at awkward points in the workflow.
If search was unavailable after a document commit, the primary operation could still succeed. An outbox record preserved the projection update, and the indexer could catch up. Search had a real recovery boundary.
If notification was unavailable, delivery could still complete. The notice remained pending and could be retried with its own idempotency identity. Again, a real recovery boundary existed.
If the renderer failed halfway through document creation, however, the API service could not say what the user request meant. The workflow had been divided at a technical operation while the user-visible state remained one concept. I had to invent intermediate statuses—metadata-created, render-requested, render-started, render-complete—and then teach every reader which combinations were valid.
Some intermediate states were useful operational evidence. Exposing all of them as durable product states was a tax created by the split.
Moving rendering behind the document module let one local transaction create the document revision and the durable render intent. The CPU-heavy work still ran asynchronously, but the state machine belonged to one capability. The API could describe draft, rendering, ready, and failed in terms a person could act on.
Recovery did not require every step to share a process. It required one place to own the invariant.
Transactions were an architectural signal
The strongest consolidation candidates were operations for which I kept wishing for a distributed transaction.
Creating a document revision, recording its template version, establishing its access policy, and appending an audit fact needed to succeed together. I had spread those writes across services and then compensated for partial completion with queues, reconciliation jobs, and administrative repair commands.
The machinery was technically interesting. It was also evidence that the boundary cut through a single decision.
I moved those tables into one PostgreSQL database with module-specific schemas and restricted data access through package APIs. One transaction could now uphold the local invariant. External effects—delivery, notifications, search—were requested through an outbox committed in that same transaction.
This was not “strong consistency everywhere.” It was strong consistency where the product needed one decision, followed by explicit asynchronous work where delay and independent recovery were acceptable.
The service map had made both categories look equally distributed. The product did not.
Network contracts were not automatically better contracts
Several weak module interfaces had looked respectable because they used HTTP and JSON.
The rendering endpoint accepted a large object containing document metadata, template settings, access fields, and delivery hints. Callers depended on fields the renderer did not conceptually own. Removing a field required a versioning exercise even when the change remained inside one deployable product.
Consolidation exposed the same coupling as an ordinary TypeScript import. That visibility was useful. I replaced the broad object with a narrow RenderRequest containing a document revision identifier, template revision, locale, and deterministic asset references. The package could not reach controller state or query unrelated tables. Contract tests exercised the public surface.
The interface became stronger after the network disappeared because I could no longer mistake transport ceremony for conceptual clarity.
Where the network remained—external adapters and separately run workers—the same contract was serialized with an explicit version and idempotency key. A boundary could move between in-process and out-of-process execution without changing its meaning.
I used a boundary ledger
To keep the audit from becoming a philosophical argument, I recorded each boundary in a small ledger:
| Boundary | Independent change | Distinct scale | Separate authority | Independent recovery | Consistency tolerance | Decision |
|---|---|---|---|---|---|---|
| Document / render | Low | Resource isolation only | No | Partial | Low for request creation | Consolidate module; separate worker shape |
| Document / search | Medium | Yes | Projection only | Yes | Lag acceptable | Keep asynchronous projection |
| Document / delivery | Medium | Yes | Effect receipts | Yes | Explicit pending state | Preserve durable adapter boundary |
| Document / audit | Low | No | Same decision | No | Must be atomic | Consolidate transaction |
The ledger included counterevidence and a review date. A decision to consolidate was not a permanent declaration that the module could never become a service. It said the current system had no evidence for paying that cost.
I also recorded the removal work. A boundary was not gone when requests stopped flowing through it. Its repository, pipeline, topic, dead-letter queue, database credentials, dashboards, alerts, image, DNS entry, and runbook all needed an owner or a deletion.
Counting deletion made subtraction feel like engineering work rather than aesthetic tidying.
Some boundaries survived
The audit did not end with one executable and no internal structure.
Delivery adapters remained isolated behind durable requests and receipts because ambiguous external outcomes needed a careful protocol. Search retained a rebuildable index and asynchronous update path because query needs and storage shape differed from authoritative writes. Resource-heavy render workers stayed separately scheduled because their load could harm interactive traffic.
What changed was the claim each boundary made.
A package boundary claimed that callers should depend on a narrow public API. A database schema boundary claimed that one module governed a set of invariants. A process boundary claimed that execution needed isolation. A queue claimed that work could complete later. A service deployment claimed meaningful operational independence.
Previously, I had used the most expensive mechanism to express all five claims. P6X4 let each claim use the smallest mechanism that enforced it.
Local development exposed the cumulative cost
The boundary audit became concrete when I measured the path from a clean checkout to one changed document.
The service version required a container runtime, eight application images, a broker, several databases, migration ordering, generated clients, local routing, and seed data synchronized across stores. The happy path took long enough that I often left the entire environment running. A failed seed in one service produced a collection of individually healthy processes that could not complete a scenario.
I wrote a deterministic scenario called subtitle-to-delivery. It started with one document fixture, added a subtitle, rendered revision two, indexed it, requested delivery, and inspected the receipt. The scenario did not mock the module contracts, but all external adapters were local deterministic fixtures.
Before consolidation, the scenario runner needed to discover service URLs, wait for asynchronous schema setup, poll several projections, and collect logs from multiple processes. A failure in the document transaction often appeared as a timeout in a downstream poll. The runner knew the topology better than the feature.
After consolidation, the authoritative transaction ran in one application database. Web, render worker, outbox relay, and indexer remained distinct runtime shapes launched from the same version. The runner could reset one database, seed one coherent fixture, and observe durable workflow states instead of inferring them from process health.
This did not make distributed testing unnecessary. Delivery adapters, broker redelivery, worker crashes, and projection lag still needed integration scenarios. It made the distribution proportional to the actual failure boundaries.
Local simplicity was not the deciding metric. A necessary distributed system can be awkward to run. In P6X4, the awkwardness revealed coordination already imposed on every feature without a matching independent need.
Authorization crossed the wrong lines
The service map had also scattered one policy across several gateways.
The document service checked whether a subject could read a revision. The renderer trusted the document request because it came from an internal network. Delivery checked a role copied into a token. Search filtered results using an access field projected earlier. During one scenario, a permission was removed after search had indexed the document but before a stale delivery request was handled.
There was no single answer to what authorization meant at each step.
Consolidation did not mean evaluating one generic isAllowed function everywhere. I separated decisions by time and consequence:
- Interactive reads checked current document policy.
- A render command included the authorized source revision and policy decision context recorded when requested.
- Delivery revalidated current authority before accepting a new intent, then preserved the exact accepted request for attempts.
- Search treated authorization fields as security-sensitive projections and fell back to authoritative filtering when freshness was uncertain.
The policy package owned vocabulary and decision functions. Modules supplied their relevant resource and action. Negative tests asserted that the renderer and delivery code could not bypass the public decision surface by reading policy tables directly.
Previously, a network hop had been treated as a trust boundary without a coherent authorization boundary behind it. Bringing code into one process made accidental trust easier, so I enforced import and database access rules deliberately. The result was stricter than the internal-service convention it replaced.
I rehearsed reversal before deleting anything
The audit produced recommendations, but consolidation still carried migration risk. I avoided a flag-day merge.
For one capability at a time, a strangler route directed a deterministic subset of synthetic requests to the in-process module. Both paths consumed the same versioned input contract. During a shadow phase, the new path produced a result without performing external effects, and a comparator classified differences in domain output rather than raw timestamps or generated identifiers.
Authoritative writes moved only after the new module passed failure scenarios and the old service became a read-only follower. A write fence prevented both sides from accepting new authority at once. The rollback window retained the old reader and its compatible schema; it did not promise indefinite dual writes.
Every migration plan named the point after which rollback meant a forward repair instead of routing back. That point was usually an irreversible external effect or a schema cleanup. Pretending reversal remained free would have extended the duplicate architecture permanently.
I rehearsed worker interruption, outbox replay, stale clients, and restoration from backup before removing a database. A service repository was archived only after its last runtime credential, topic subscription, dashboard, and deployment target disappeared.
The deletion phase supplied final evidence. If retiring a boundary required months of compatibility because independent consumers truly relied on it, the audit had underestimated its life. If nothing outside P6X4 noticed, the old box had been more operationally independent than conceptually independent.
The test changed how I saw diagrams
I still draw boxes. I no longer treat a box as evidence.
A useful service boundary has an independent life: a reason to change, scale, authorize, fail, recover, or evolve without dragging its neighbour through the same transition. The reason can emerge later. It can also disappear as a product changes. Architecture is allowed to notice.
The subtitle feature became deliberately boring after consolidation. I changed one domain type, one migration, one rendering contract, and one set of scenario tests. Search caught up through the outbox. Existing delivery snapshots retained their original title. No coordinated release was necessary.
The reduced diagram was not proof that the system had become less capable. It showed that fewer deployment boundaries were pretending to be decisions.
I had learned to ask whether both sides could live separately before celebrating the line between them.