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/contractis 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:
- 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'suser-side/service. There is no "just this once" import to hunt down at extraction time, because it would not have compiled. - The consumer owns the port. When
billingneeds order data, the port lives inbilling/domain/contractin billing's vocabulary —OrderLookup, notOrderingService. Billing's domain therefore has no opinion about ordering at all, and swapping the implementation underneath is invisible to it. - Assembly is a separate module.
application/apiis 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:
- Stand up the new assembly. Add
application/ordering-apilisting onlymodules/orderingand theplatform/modules it needs. It gets its own Dockerfile and its own IaC stack. The existing assembly stops listingmodules/ordering. - Give the peer a remote adapter. Add
modules/billing/infra/ordering-httpnext to (not instead of) the existing in-process adapter. It implements the sameOrderLookupport that billing already declared, built againstordering/user-side/api/contract— the artifact the modulith was already publishing. - Flip the wiring. The remaining assembly binds
OrderLookupto 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. - 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.
- Re-read the port's semantics in the consumer's domain.
OrderLookupcan 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. - 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/serviceseam. - 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
- Hexagonal architecture — the layout this note reads as a
position on the spectrum; the
user-side/serviceseam is defined there. - Microservice architecture — the destination, its prerequisites and its costs in full.
- DDD — bounded contexts are what
modules/is carved along; get these wrong and neither model saves you. - Coupling & cohesion — the framework for judging whether a module boundary is in the right place before it becomes a network boundary.
- Asynchronous and distributed system patterns — sagas, outbox and the consistency machinery step 5 hands you.
- Observability & SRE practice — the operational prerequisite extraction adds; an in-process call that becomes a hop needs correlation & context propagation.
- The four hexagonal references compared — how much of the carve-out property each language actually delivers, and what enforces it.
- Reference implementations realizing the layout, each with its own carve-out story: Java, Go, Rust, Frontend.