rgoussu@goussu: ~/library/applicative-architecture
~/library/applicative-architecture cat modulith-and-microservices.md

Modulith & microservices

# Why the house layout starts as a modulith and how its in-process service seam turns the move to microservices — and back — into a wiring change rather than a rewrite.

Conceptsaved 2026-08-14 #modulith#microservices#hexagonal-architecture#architecture#ddd#distributed-systems

Overview

A modulith is a single deployable built from strictly separated modules — one hexagon per bounded context, no shared domain, no shared database schema. It sits between the big ball of mud and the microservice fleet, and it is where the house hexagonal layout deliberately starts. The claim this note makes is narrower than the usual "modular monolith is fine" argument: because modules only ever meet at the in-process service adapter, and because what runs together is decided in application/, extracting a module into its own service is a change of wiring and deployment topology — not a change of either domain.

Key points

  • Both models are the same architecture at different assembly settings. A module's hexagon does not know whether the peer behind its driven port is a method call or a network hop; only its infra/ adapter knows, and only the assembly chooses.
  • Start as a modulith, always. Boundaries are the expensive thing to get wrong and the cheap thing to move while they are still in-process. A distributed system built on guessed boundaries is a distributed monolith — the failure mode everyone warns about (microservice architecture).
  • The carve-out seam is infra → peer user-side/service. It is the only legal inter-module edge, it is already an indirection, and it is already expressed as a port the consumer owns. Swapping its implementation is the whole extraction.
  • The published contract already exists. A module's user-side/api/contract is a consumable artifact by construction — the same DTOs and interfaces a remote client would need. Extraction consumes an artifact the modulith was already producing.
  • Extraction is triggered by operations, not by aesthetics. Independent scaling, independent release cadence, isolation of a failure domain, team ownership, regulatory separation. "It feels big" is not a trigger; a module that is merely large gets split into two modules, not two services.
  • What crosses the wire changes semantics, not shape. The port signature survives; its failure modes, latency and consistency guarantees do not. That re-reading — not the refactor — is the real cost, and it is paid in the consumer's domain, once.
  • The move is reversible. Folding a service back in is the same operation with the adapter swapped the other way, which is what makes early boundary guesses survivable.

Details

The spectrum, and where the layout sits

Big ball of mud Modulith (house default) Microservices
Deployables 1 1 per delivery typology 1 per context, or more
Domain isolation none compile-enforced per module process-enforced
Peer call direct method call anywhere driven port → peer user-side/service driven port → remote client
Consistency one transaction one transaction, by default eventual, sagas, outbox
Boundary mistake costs a refactor a directory move a migration project
Ops prerequisite none none CI/CD, tracing, on-call maturity

The modulith column is the only one where a boundary can still be wrong cheaply. The whole point of paying its ceremony up front is to keep that column available for as long as possible while the domain is still being learned.

Why carve-out is cheap in this layout

Three properties of the house layout do the work, and all three are structural rather than disciplinary:

  1. Modules never touch each other's domains. The build graph permits exactly one inter-module edge: a module's infra/ may depend on a peer's user-side/service. There is no "just this once" import to hunt down at extraction time, because it would not have compiled.
  2. The consumer owns the port. When billing needs order data, the port lives in billing/domain/contract in billing's vocabulary — OrderLookup, not OrderingService. Billing's domain therefore has no opinion about ordering at all, and swapping the implementation underneath is invisible to it.
  3. Assembly is a separate module. application/api is the only thing that knows the full module list. A new assembly containing a narrower list is a new service — there is no monolith-shaped code to disentangle, because the composition was never spread across the codebase.

The extraction, step by step

Extracting ordering out of a modulith that also hosts billing:

  1. Stand up the new assembly. Add application/ordering-api listing only modules/ordering and the platform/ modules it needs. It gets its own Dockerfile and its own IaC stack. The existing assembly stops listing modules/ordering.
  2. Give the peer a remote adapter. Add modules/billing/infra/ordering-http next to (not instead of) the existing in-process adapter. It implements the same OrderLookup port that billing already declared, built against ordering/user-side/api/contract — the artifact the modulith was already publishing.
  3. Flip the wiring. The remaining assembly binds OrderLookup to the HTTP adapter instead of the in-process one. This is one line at the composition root, and it is the only line in the system that changes meaning.
  4. Separate the data. The one genuinely irreversible step, and the reason the modulith forbids a shared schema from day one: each module owns its tables, so this is a physical move rather than a decomposition.
  5. Re-read the port's semantics in the consumer's domain. OrderLookup can now be slow, unavailable, or stale. Billing's domain decides what that means — a fallback, a cached read model, a degraded response — and it decides it once, behind a port it already owned.
  6. Delete the in-process adapter when nothing binds it, or keep it: it remains the fastest way to run the whole product in one process for local development and end-to-end tests.
flowchart LR
    subgraph before["Before — one assembly"]
        BDC1["billing/domain<br/>OrderLookup port"]
        BINF1["billing/infra/<br/>ordering-inprocess"]
        OSVC1["ordering/user-side/service"]
        BDC1 --- BINF1
        BINF1 -->|"method call"| OSVC1
    end
    subgraph after["After — two assemblies"]
        BDC2["billing/domain<br/>OrderLookup port (unchanged)"]
        BINF2["billing/infra/<br/>ordering-http"]
        OAPI["ordering/user-side/api<br/>(same published contract)"]
        BDC2 --- BINF2
        BINF2 -->|"HTTP"| OAPI
    end
    before -->|"swap one binding<br/>+ split the data"| after

Steps 1–3 and 6 are mechanical. Step 4 is a data migration and step 5 is a domain decision — those two are the honest cost of the move, and no layout removes them.

What the modulith does not buy you

  • It is still one failure domain until you split. A memory leak in one module takes the assembly down with it. That is the trade being made deliberately, not an oversight.
  • It does not enforce transactional discipline by itself. Modules sharing a process can share a transaction, and code that quietly relies on that will break at extraction. Treat a cross-module call as if it were remote from the first day: no shared transaction across the user-side/service seam.
  • Independent release cadence is not free. One assembly means one deployment; teams wanting to ship on their own clock want their own assembly, which is the extraction.
  • It is not an excuse to skip boundary work. A modulith with wrong module boundaries is a distributed monolith waiting to happen — it has simply not paid for the mistake yet.

Going the other way

Consolidation gets far less press than decomposition and is the more common correction. It is the same procedure inverted: point the consumer's port back at the in-process adapter, merge the assemblies, move the data back. It is cheap for exactly the same reason — the consumer's domain never encoded the peer's location. A fleet that was not built this way generally cannot be re-consolidated at all, which is why the reversibility is worth the up-front ceremony even for teams certain they will never need it.

Practice

  • Strangler fig migration (source) — carve one bounded context out behind a routing facade; the modulith seam is the facade you already have.
  • Saga, twice (source) — the consistency guarantee step 5 takes away, rebuilt by hand, choreographed then orchestrated.
  • Extract and fold back — run the extraction on a two-module toy modulith, then reverse it; the exercise is over when both directions are one binding change plus a data move.

Related