P6X4

A staged consolidation of an over-separated service estate into a modular monolith with fewer, stronger boundaries.

Personal project

Project brief

Period
2022
Status
Archived
Focus
Architectural consolidation
Constraint
Reduce deployment boundaries without losing domain ownership.
Result
A modular monolith kept durable contracts while removing avoidable coordination.

P6X4 was a 2022 personal project about architectural subtraction. Z29C had clean adapter contracts, durable events, and independent workers. Those boundaries made it easy to imagine every capability as a separately deployed service. I built a synthetic document-and-fulfilment application that way and kept splitting until ordinary changes crossed too many repositories, queues, and dashboards.

I split the system because I could

The service map looked sophisticated: identity, catalogue, workflow, document generation, delivery, notification, audit, and search each had its own process. Several components had independent databases. Events connected them. Local development required containers, seed coordination, and patience.

The system worked. That was the problem. There was no dramatic failure to force a correction. Instead, small changes became slower and harder to reason about. Adding one field to a document required schema changes, event-version changes, consumer rollouts, and temporary compatibility. A transaction that conceptually belonged together became an eventually consistent choreography with a recovery plan.

P6X4 asked which boundaries earned deployment independence and which were expensive drawings around code that changed together.

The project was a self-directed architecture laboratory, not a fictional company migration. I created the over-separated system, collected evidence from its change patterns and failure scenarios, then consolidated it while keeping the durable contracts that still mattered.

The case against the current architecture

I did not begin with “microservices are bad.” I wrote a boundary inventory for every service:

  • Which data did it own?
  • Which changes required coordinated edits elsewhere?
  • Could it be deployed, scaled, and recovered independently in practice?
  • What failure did its network boundary contain?
  • Which events represented real asynchronous facts, and which simulated a function call?
  • What operational work existed only because of the split?

Some boundaries remained strong. External adapters needed isolation from provider latency and credentials. Z29C workers had execution and scheduling behavior distinct from web requests. Search indexing was asynchronous and rebuildable.

Other services were inseparable in everything except deployment. Document metadata and workflow state changed together. Notification routing depended on internal details of decisions it could not own. Audit events duplicated data from several sources and still required joins across them.

Repository history from the synthetic development period showed clusters of files and services that changed together. I used that evidence as a clue, not an automatic architecture generator. A co-change can indicate a missing abstraction, an immature boundary, or one cohesive capability.

The case for consolidation rested on reduced ambiguity and operational surface, not nostalgia for a monolith.

Deployment map

Separate because it can run separately

Network edges multiplied even when code, data, and releases still changed together.

Change boundary

Separate when ownership survives change

Domain contracts remained explicit while closely coupled work returned to one transactional and deployable unit.

Defining the modular monolith

P6X4 became one TypeScript application with explicit capability packages, one PostgreSQL cluster with schema ownership conventions, and separate process entry points for web, workflow workers, and indexers.

The packages were:

identity
documents
decisions
workflows
delivery
notifications
audit
search

Each package exported a small application API. Internal tables and implementation modules were not imported across boundaries. Cross-package calls used typed commands and queries inside the process. Domain events remained for asynchronous reactions and external integration.

One deployable did not mean one undifferentiated directory. The architecture tests enforced import direction and public entry points. Database migrations named the owning package. Shared code was restricted to platform utilities with no domain policy.

The distinction mattered: consolidation removed network and deployment boundaries where they added no value. It did not remove conceptual ownership.

The usual strangler story replaces a monolith with services. P6X4 used the same routing idea to move service behavior inward.

One capability at a time gained an in-process implementation behind its existing contract. A route or event consumer could direct a controlled set of synthetic accounts to the new path while the old service remained available.

The migration sequence was:

  1. Record the current contract and failure behavior.
  2. Add comparison fixtures from representative requests and events.
  3. Implement the package API inside P6X4.
  4. Copy or project required data into the new ownership boundary.
  5. Shadow safe reads and compare normalized results.
  6. Route a controlled subject set to the new path.
  7. Stop old writes and reconcile final state.
  8. Remove the network contract and service infrastructure.

I treated removal as part of the migration. A service that remained deployed “just in case” continued to cost attention and weakened the new source of truth.

Routing decisions were observable and reversible during the migration window. A subject never bounced between implementations within one workflow without an explicit compatibility rule.

Data ownership during consolidation

Moving code was easier than moving authoritative data. The over-split system had duplicated document summaries, workflow status, and user labels across databases.

For each capability, I named the canonical fields and classified every copy as cache, projection, snapshot, or accidental second source. The migration plan established how each copy would be rebuilt or removed.

Initial data entered P6X4 through a versioned import with counts, checksums, and rejected-record reports. During dual operation, the old service emitted changes into an outbox. P6X4 applied them idempotently while comparison ran.

Cutover created a clear write fence. The old service stopped accepting new mutations, the final outbox position was recorded, P6X4 caught up, invariants ran, and routing changed. If verification failed, the old service could reopen before any P6X4-only write had been accepted.

After P6X4 became authoritative, events flowed outward only for real asynchronous consumers. I did not maintain permanent bidirectional synchronization to preserve rollback fantasy. The rollback window was bounded and designed.

Database constraints moved closer to the data. Relationships previously enforced by best-effort events could use foreign keys and transactions when they now lived within one cohesive capability.

The document-approval flow had been spread across document, workflow, and audit services. Approving a revision emitted an event; another service changed workflow state; a third recorded an audit copy. Partial delivery produced states the user could never intentionally request.

Inside P6X4, the approval command validated the current immutable revision, checked authority, recorded the decision, advanced workflow state, and appended a domain event in one database transaction.

The domain event entered a transactional outbox and was published after commit for notification and search. Those consumers were allowed to lag because the core decision did not depend on their immediate completion.

This was not a return to one global transaction for everything. External delivery still crossed a real boundary and used Z29C’s receipts. Search remained a rebuildable projection. The change was aligning transactional boundaries with user-visible invariants.

I wrote an invariant for every consolidated command. “An approved decision always references an existing immutable document revision” belonged in one transaction. “Search reflects the approval within a short period” belonged in asynchronous monitoring.

The architecture became easier to discuss because consistency choices followed product semantics rather than service topology.

Events after the event enthusiasm

The original system emitted events for nearly every state change. Some described meaningful facts. Others existed so one service could ask another to perform a synchronous step indirectly.

P6X4 classified them:

  • Domain event: a durable fact useful to multiple asynchronous consumers.
  • Integration event: a versioned fact crossing the application boundary.
  • Internal notification: an in-process mechanism that did not deserve durable public history.
  • Command disguised as event: a request with an expected owner and outcome.

Commands disguised as events moved to typed package APIs or Z29C workflows. Internal notifications often became ordinary function calls. Domain and integration events kept stable schemas and outbox identity.

I removed topics, consumers, retry policies, and dead-letter queues that no longer represented asynchronous work. The system lost visible machinery and gained clearer failure semantics.

Event removal required care. A consumer might have become an undocumented dependency. I added usage instrumentation, searched code and deployment configuration, disabled consumption in a reversible stage, then removed production of the event.

The goal was not fewer events as an aesthetic. It was events only where time and autonomy genuinely separated work.

Package boundaries that tests could enforce

TypeScript path aliases can make every module importable, so P6X4 used lint and architecture tests to restrict dependencies. A capability could import another only through its public application API or stable event types.

Circular package dependencies failed the build. Shared domain types did not enter a global folder by default; the capability that owned the concept exported the narrow type needed by consumers.

Database access followed the same rule. Each package exposed repository functions for its tables. Cross-capability SQL was allowed only in named reporting projections and never in mutation paths.

Contract tests exercised public package APIs against the real database. They focused on inputs, outputs, invariants, and domain errors rather than HTTP serialization.

The old services’ integration fixtures became migration tests. Once a service was removed, the fixture still protected the behavior P6X4 had intentionally retained. Fixtures representing accidental or harmful behavior were documented and retired rather than preserved forever.

These boundaries were lighter than network isolation and strong enough for the personal project. They made accidental coupling visible during development, where it was cheapest to correct.

P6X4 built one versioned artifact and ran it through several entry points: web, workflow worker, outbox publisher, and search indexer. They shared code and migrations while retaining different resource and scaling profiles.

A release manifest named the commit, artifact digest, schema migration set, and enabled capability versions. Deployment applied additive migrations, started the new artifact, verified runtime configuration, and shifted traffic.

Long-running Z29C workflows retained their definition and handler versions. A P6X4 deploy could not reinterpret them. Workers advertised which versions they supported, and the scheduler selected compatible work.

The web process remained free of adapter credentials it did not use. Consolidating code did not require consolidating authority. Runtime entry points received scoped configuration and credentials.

Failure containment became explicit. A memory leak in a search indexer could restart that process without taking web traffic, even though both ran the same artifact. A database remained a shared dependency and received resource budgets by process class.

This design demonstrated that deployable, process, module, data, and security boundaries are different tools. The original system had treated them as one choice.

A simpler local environment

Before P6X4, local development required several service containers, message infrastructure, database seeds, and network routing. Startup order itself was a source of failure.

The consolidated environment used one application, PostgreSQL, and stubbed external adapters. Workflow and indexer processes could run only when the scenario needed them. A single seed created a coherent set of documents, decisions, and workflows.

The same scenario harness ran against the in-process packages and the deployed entry points. A developer could test a command quickly without losing the integration path.

I measured environment usefulness by recovery: could a clean checkout explain a failed startup, rebuild its data, and run one meaningful workflow? Reducing boot time was helpful; reducing the number of hidden states mattered more.

The old environment remained available during migration tests, pinned by manifests. It was removed after the last service retirement. Keeping both forever would have doubled maintenance under the name of safety.

Streaming without multiplying loading states

React 18’s stable 2022 release made streaming server rendering with Suspense a prominent architecture option. P6X4 used it carefully for the document overview.

The first experiment wrapped every data region in its own fallback. The page arrived quickly and filled with spinners that shifted independently. Technical concurrency had become visual fragmentation.

I reorganized the page around stable user concepts. Identity, title, and primary action arrived in the initial shell. A secondary activity region streamed when it became available. Related metadata shared one boundary because partial display did not help the task.

The server rendered authoritative document and decision state. Small client components handled local interaction. I did not move every package API behind client fetches merely because the backend had consolidated.

Streaming was evaluated by when meaningful structure became usable, layout stability, and behavior under a slow secondary query. The feature stayed where it clarified progressive arrival and was removed where it created spinner theatre.

This interface work connected architectural subtraction to user experience. Fewer network boundaries did not automatically create a coherent page; the loading model still needed to express the task.

Migration evidence

The P6X4 dashboard showed migration status by capability:

  • Current authoritative implementation.
  • Read and write routing percentages.
  • Import and outbox positions.
  • Shadow-read differences by category.
  • Invariant results.
  • Old infrastructure still active.
  • Removal conditions.

Differences were normalized before comparison. Timestamps within known precision and order-insensitive sets did not produce noise. Missing fields, changed domain status, or permission differences did.

Every cutover produced a receipt containing the old final position, new starting position, invariant results, routing change, and rollback deadline. The receipt made the migration reviewable after the dashboard moved on.

I tracked architecture removal alongside feature completion. Deleting a queue, service deployment, database, or compatibility adapter was a visible outcome. Without that accounting, consolidation could become another layer around the same estate.

The final service map was less exciting and easier to explain. That was the desired result.

Performance after removing the network

I expected consolidation to make every path faster and learned to separate network removal from query design. One document page no longer made several HTTP requests between services, but its new in-process query loaded far more related data than the interface used.

P6X4 established query budgets by route and command. The budgets covered database statements, rows scanned, external adapter calls, and serialized response size. A development trace connected each query to the package API that requested it.

The document overview used one purpose-built read projection rather than calling every capability package and assembling a domain object graph. Mutation paths stayed inside owning packages; cross-capability read models could join data when their ownership and rebuild path were explicit.

Indexes were justified by observed query shapes and verified against representative data. The synthetic data generator produced enough documents, revisions, decisions, and workflow receipts to reveal scans that a tiny seed would hide.

Consolidation reduced serialization, network retries, and duplicated caches. It also made a shared database easier to overload accidentally. Process-specific connection budgets and statement timeouts protected interactive work from index rebuilds and background workflow polling—the exact resource lesson that had triggered W93H.

I ran the old and new paths under the same scenario load. The report included latency distribution, database time, request count, failure behavior, and result differences. I avoided claiming a universal speedup. Some asynchronous paths remained slower because P6X4 waited for a stronger transactional invariant before acknowledging success.

The performance review reinforced the project’s main discipline: remove a boundary only after naming the responsibility it used to contain. A network hop can be wasteful, but it can also hide resource competition that must be made explicit after consolidation.

The service architecture had encouraged every service to perform its own coarse token check. After consolidation, an internal function call could accidentally bypass those edges.

P6X4 moved authorization into capability commands. Each command received an explicit actor and policy context, loaded the resource under its owning package, and returned typed denial reasons. The web route, worker, and test harness all used the same application API.

Background workflows acted through scoped service identities tied to workflow and step receipts. They did not inherit the web process’s broad authority. Package boundaries could therefore share memory without sharing all permission.

I added negative contract tests for cross-capability calls. A notification handler could read the limited decision summary needed to compose a message and could not load an unpublished document body. A search indexer could receive a projection event and could not invoke mutation commands.

The audit package received semantic authorization and decision events through the transactional outbox. It did not query every private table to recreate policy after the fact.

This work prevented “monolith” from becoming shorthand for “trusted internal code.” A boundary can be in-process and still carry authority, data minimization, and testable denial behavior. The deployment topology changed; the security model still needed explicit subjects and capabilities.

Boundaries I defended for too long

I originally equated a clean conceptual boundary with a separate deployable. Co-change, transactions, scaling, recovery, and authority did not support many of those splits.

The first consolidation plan tried to move several services in one flag day. Building capability-by-capability routing, import, comparison, and removal made progress reversible.

Permanent bidirectional synchronization looked like rollback safety and created two sources of truth. A bounded write fence and explicit rollback window replaced it.

The old system treated every fact as an event. Classifying domain facts, integration facts, internal notifications, and disguised commands removed unnecessary asynchronous failure.

The first streaming UI exposed every backend boundary as a loading boundary. Grouping by user task restored a stable hierarchy.

P6X4 demonstrated that increasing skill can produce less visible architecture. It took a distributed system I had built for the exercise and consolidated it without discarding the durable workflow, policy, and evidence patterns that had earned their complexity.

The project added disciplined subtraction to the journey. It required data migration, transactional redesign, package enforcement, version-compatible workers, staged routing, and infrastructure removal. Deleting a service responsibly was more demanding than drawing one.

Z29C’s receipts remained around real external effects. W93H’s evidence shaped migration receipts. J05N’s progressive adoption shaped the strangler path. Q2F8’s authority boundaries survived even when code shared one repository.

K81R, the next project, would build on P6X4’s consolidated document model and add hybrid search plus generated answers. The architectural lesson would remain important: a model boundary should enter only where it improves the product, and its uncertainty must not leak into authoritative state.

What consolidation did not solve

P6X4’s over-separated estate and migration were self-created. The project did not reproduce contractual ownership, independent business units, or the organizational reasons real services sometimes remain separate. Its evidence applied to the synthetic system’s actual change and failure patterns.

The modular monolith retained shared-database and shared-deployment risks. Process isolation reduced some runtime coupling without creating independent deployability. The result was not universally better architecture; it was a better fit for this project’s cohesion, scale, and maintenance capacity.

Technology: TypeScript capability packages, PostgreSQL, transactional outbox events, versioned Z29C workers, staged strangler routing, import and comparison fixtures, architecture tests, scoped runtime entry points, and React 18 streaming used at task-level boundaries.