LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

The Modular Monolith

modular-monolithmicroservicessoftware-architecturemonolithdistributed-systemssystem-design

Somewhere around 2015 the industry collectively decided that the monolith was the enemy, and for the better part of a decade the reflexive answer to almost any scaling, velocity, or team-coordination problem was the same: split it into microservices. The advice was wrong for most of the teams who took it, and the reason it was wrong is not subtle. The thing those teams actually wanted was modularity — clear boundaries between parts of the system, the ability to reason about one piece without dragging in the whole, the freedom to change the inventory code without breaking checkout. Microservices deliver modularity, but they bundle it with a tax that has nothing to do with modularity and everything to do with the network: every method call becomes a remote call that can hang, time out, or partially fail; every transaction that used to commit atomically becomes a choreography of compensations; every “where did this request go” becomes a distributed-tracing project. You can have modularity without paying that tax. The architecture that gives it to you is the modular monolith, and the honest version of the last decade is that most of the rewrites should never have happened.

A modular monolith is not a return to the big ball of mud, and that confusion is why the term needs defending. It is a single deployable unit — one process, one build, one deploy — composed of modules with strict, enforced boundaries: each module exposes an explicit public interface, hides its internals, owns its own data, and may not reach into another module’s private guts. The difference between this and microservices is that the calls between modules are in-process function calls, not network requests. The difference between this and a traditional monolith is that the boundaries are real and enforced by tooling, not aspirational comments in a wiki. You get the architectural clarity people went to microservices for, and you keep the operational simplicity — one thing to deploy, one place to look, real ACID transactions — that microservices throw away. This post is the case for that architecture, made fairly, including the cases where it is the wrong choice.


What the microservices era got right, and what it forgot

It is worth stating plainly what microservices solved, because the modular monolith does not solve all of it and pretending otherwise is dishonest. Microservices solved an organizational problem first and a technical one second. When you have forty teams and one deployable, every release is a coordination negotiation; the slowest, riskiest team gates everyone. Splitting into independently deployable services lets each team ship on its own cadence, choose its own stack, and scale its own hot path without asking permission. That is a genuine win, and at sufficient organizational scale it is the only thing that works. The classic statement of this is Conway’s Law — your architecture will mirror your communication structure whether you plan it or not — and microservices are, properly understood, an org chart expressed as deployment topology.

What the era forgot is that almost nobody had forty teams. The pattern was designed at Netflix and Amazon scale and then cargo-culted into companies with eight engineers and one product, where it bought every cost of distribution and almost none of the benefit. The benefits of microservices are organizational and only materialize at organizational scale; the costs are technical and show up on day one. A three-team startup that splits its product into twelve services has not bought independent deployability — those services still ship together because they share a release train and a domain that changes as a unit. What it has bought is the network sitting in the middle of its own codebase.


The distributed-systems tax, itemized

The phrase “distributed-systems tax” sounds rhetorical. It is not; it is a specific, enumerable set of costs that the network imposes the moment a call crosses a process boundary, and every one of them is something a modular monolith does not pay. The foundational reading here is the fallacies of distributed computing, the list Peter Deutsch and others assembled at Sun: the network is reliable, latency is zero, bandwidth is infinite, the network is secure, topology doesn’t change, there is one administrator, transport cost is zero, the network is homogeneous. Every one of those is false, and microservices make you confront all of them.

Modular monolith: one process, in-process calls
=================================================

  +-------------------------------------------------+
  |                  app (one binary)               |
  |                                                  |
  |  [orders] --call--> [inventory] --call--> [pay] |
  |     |                   |                   |    |
  |     +------ all share one DB, one TX -------+    |
  +-------------------------------------------------+
                          |
                     deploy: 1 unit

  call cost: nanoseconds   |   failure modes: throws/returns
  transaction: real ACID   |   tracing: a stack trace


Microservices: the same call graph, now over the network
=========================================================

  [orders]  --HTTP/gRPC-->  [inventory]  --HTTP/gRPC-->  [pay]
     |  retries, timeouts,       |  circuit breakers,        |
     |  TLS, service discovery    |  load balancing,          |
     v                            v  backpressure             v
   DB-orders                   DB-inventory                DB-pay
     \____________ no shared transaction; use a saga _______/

  call cost: 0.5-50 ms     |   failure modes: + timeout, hang,
  transaction: distributed |     partial success, lost response
  tracing: a span pipeline |   deploy: N units, N pipelines

Walk the list concretely. Latency and failure: an in-process call is nanoseconds and either returns or throws. A network call is hundreds of microseconds to tens of milliseconds and adds entire new failure modes — it can time out, hang, succeed-but-lose-the-response, or partially succeed. Chain five synchronous calls and your latency is their sum and your availability is their product; five services at 99.9% each give you 99.5%, four times the downtime. Transactions: inside one process you wrap three module calls in a single database transaction and it either all commits or all rolls back. Across services there is no shared transaction. You reach for the saga pattern — local commits with compensating actions to undo them on failure — and you have turned a five-line transaction into a stateful workflow with its own failure modes. How microservices patterns survive this is its own subject; the point here is that the modular monolith never has the problem.

Consistency: the moment data lives in separate service databases, you give up reading your own writes and inherit eventual consistency, which you then have to design the UX around. Observability: a stack trace is replaced by distributed tracing — you instrument every service, propagate context, ship spans to a backend, and learn to read flame graphs across process boundaries just to answer “what happened to this request.” Operations: you now run a service mesh for mTLS and retries, an orchestrator like Kubernetes to schedule it all, and one CI pipeline per service. None of this is the product. It is the cost of having put the network in the middle of your own code.


A side-by-side that respects all three

The honest comparison is not monolith-bad, microservices-good. It is three points on a spectrum with different cost structures, and the modular monolith sits deliberately in the middle.

Property Big ball of mud (legacy monolith) Modular monolith Microservices
Internal coupling High, implicit, unenforced Low, explicit, tool-enforced Low, enforced by the network
Boundary enforcement Comments and good intentions Compiler / ArchUnit / build rules Physical (separate processes)
Deployability One unit, must deploy whole One unit, must deploy whole N units, independent cadence
Transaction integrity Real ACID Real ACID Sagas, eventual consistency
Inter-module call cost Nanoseconds Nanoseconds 0.5-50 ms, can fail
Independent scaling No No (scale the whole process) Yes, per service
Observability needs Logs + stack traces Logs + stack traces Tracing, metrics pipeline, mesh
Operational surface One process One process Mesh, orchestrator, N pipelines
Team scaling ceiling Low (everyone in one mess) Medium (clear ownership, one repo) High (true team autonomy)
Refactoring across boundaries Trivial and dangerous Trivial and safe Expensive (coordinated deploys)

Read down the “modular monolith” column and the pitch is clear: it keeps every operational and correctness advantage of the monolith — one deploy, real transactions, stack-trace debugging, free refactoring across modules because it is all one compilation unit — while buying the boundary enforcement and ownership clarity that legacy monoliths lacked. The one thing it genuinely cannot do is scale or deploy modules independently. That single row is the entire reason microservices exist, and whether it matters to you is the whole decision.


Enforcing boundaries so it does not rot

The skeptic’s objection is correct: a modular monolith is one bad sprint away from a big ball of mud, because nothing physically stops a developer from importing another module’s internal class. The answer is that the boundary has to be enforced by tooling, the same way the network “enforces” it for microservices — except cheaper and without the latency. There are several idiomatic ways to do this depending on your stack.

In the JVM world, Spring Modulith makes modules a first-class concept. Each top-level package under your application root is a module; by default its sub-packages are internal and only the module’s top-level types are part of its public API. A test verifies the rules at build time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@ApplicationModuleTest
class ModularityTests {

  @Test
  void enforcesModuleBoundaries() {
    var modules = ApplicationModules.of(EcommerceApplication.class);
    // Fails the build if any module reaches into another module's
    // internal (non-exposed) package, or if a cycle exists.
    modules.verify();
  }
}

If com.shop.order tries to import a class from com.shop.inventory.internal, verify() fails and the build is red. No deploy required to catch the violation; you catch it in CI in milliseconds. You can express richer rules with ArchUnit directly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@AnalyzeClasses(packages = "com.shop")
class ArchitectureRules {

  @ArchTest
  static final ArchRule modules_only_talk_through_apis =
      classes()
        .that().resideInAPackage("..order..")
        .should().onlyDependOnClassesThat()
        .resideInAnyPackage("..order..", "..inventory.api..", "java..", "..shared..")
        .as("order may use inventory's public api, never its internals");

  @ArchTest
  static final ArchRule no_cycles =
      slices().matching("com.shop.(*)..").should().beFreeOfCycles();
}

In Go, the boundary is enforced by the language itself through the internal/ directory convention — any package under an internal/ folder can only be imported by code rooted at internal/’s parent, and the compiler refuses anything else:

cmd/app/main.go                 // wires modules together
internal/
  order/
    api.go                      // exported: Service, PlaceOrder()  <- the seam
    repository.go               // unexported internals; invisible outside order/
  inventory/
    api.go                      // exported: Service, Reserve()
    store.go                    // package inventory can never import order/repository.go
  platform/
    db/                         // shared infra, intentionally public to modules

The call from order to inventory is a plain method call on an interface — the in-process module API — instead of a network round trip:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// In-process module API: a Go interface, satisfied by inventory.Service.
// Compare with the microservices version, which would be an HTTP/gRPC client.
type InventoryPort interface {
    Reserve(ctx context.Context, sku string, qty int) error
}

func (s *OrderService) Place(ctx context.Context, o Order) error {
    // Nanoseconds, not a network hop. Either returns nil or an error;
    // no timeout, no retry, no circuit breaker, no partial success.
    if err := s.inventory.Reserve(ctx, o.SKU, o.Qty); err != nil {
        return fmt.Errorf("reserve failed: %w", err)
    }
    return s.repo.Save(ctx, o)
}

Had inventory been a separate service, that Reserve call would instead instantiate a gRPC or HTTP client, carry a timeout and retry policy, propagate a trace context, and fail in ways the in-process version cannot. The same logical boundary; radically different cost. In .NET the convention is to give each module its own assembly and let project references encode the allowed dependency graph, with NetArchTest or a Roslyn analyzer failing the build on a forbidden reference. The principle is identical across all of them: make the illegal dependency not compile, and the boundary stays as solid as a network would make it, for free.


One database, many schemas: data ownership without distributed data

The hardest boundary to keep honest is data, because a shared mutable database is how monoliths actually rot — everyone reads everyone’s tables, and the schema becomes a global variable. The microservices answer is database-per-service, which is also what creates the distributed-data problem in the first place. The modular monolith takes the middle path that gets the ownership without the distribution: one physical database, one schema per module, and a rule that no module reads another module’s tables directly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- One database, one connection pool, one transaction manager.
-- Each module owns a schema; cross-schema reads are forbidden by convention
-- AND by permissions, so the rule has teeth.

CREATE SCHEMA orders;
CREATE SCHEMA inventory;

CREATE TABLE orders.orders     (id uuid primary key, sku text, qty int, status text);
CREATE TABLE inventory.stock   (sku text primary key, available int);

-- The orders module's DB role can touch orders.* and nothing else.
CREATE ROLE orders_module;
GRANT USAGE ON SCHEMA orders TO orders_module;
GRANT ALL ON ALL TABLES IN SCHEMA orders TO orders_module;
-- Deliberately NO grant on inventory.*  -> a stray join fails with a
-- permission error in CI, not a silent cross-module dependency in prod.

Because it is one database, a workflow that spans orders and inventory still commits in a single ACID transaction — BEGIN; insert into orders.orders; update inventory.stock; COMMIT; — and there is no saga, no outbox, no two-phase commit, no eventual-consistency window to design around. The module that owns inventory is the only code permitted to write inventory.stock; other modules ask for changes through the in-process API, which is exactly the discipline microservices enforce physically. You have separated ownership from deployment, which is the whole trick. If you later extract inventory into its own service, the schema is already a clean seam — the migration is “give this schema its own database and put a network client where the method call was,” not “untangle a shared table nobody dared touch.”


The distributed monolith: the worst of both worlds

The reason “just do microservices” went so badly so often is a specific failure mode with a name: the distributed monolith. This is what you get when you take a tightly coupled codebase, slice it along arbitrary lines into separate services, but leave the coupling intact. The services cannot deploy independently because they share data contracts that change together; a change to one forces a coordinated release of three. Every former function call is now a synchronous network call, so you have all the latency and failure modes of distribution with none of the autonomy that was supposed to justify it. You are paying the full distributed-systems tax and receiving, in exchange, a monolith that is also slow and flaky.

The distributed monolith is strictly worse than both endpoints. It is worse than a clean monolith because it added the network. It is worse than real microservices because the boundaries are fake — the services are still coupled, just over TCP now. And it is the default outcome of splitting a system you do not yet understand, because you cannot draw correct service boundaries around a domain whose seams you have not found. This is the trap the rewrite-as-microservices advice walked thousands of teams into: split first, discover the boundaries were wrong, and now they are expensive to move because they are deployment artifacts. A modular monolith makes those same boundaries cheap to move — package structure, not infrastructure — which is precisely why you draw them there first.


The famous walk-backs: Prime Video and Shopify

Two case studies anchored the industry’s correction, and both are worth citing accurately because they are routinely overstated. In 2023, Amazon’s Prime Video team published a post describing how they took a monitoring tool built as serverless microservices — Step Functions orchestrating Lambdas, passing video frames through S3 between stages — and rebuilt it as a single process. The serverless version hit a scaling and cost wall: the orchestration overhead and the S3 round-trips between every step dominated. Collapsing it into one process that passed data in memory cut cost by about 90% and let it scale far past the previous ceiling. The nuance the headlines dropped: this was one tool, not all of Prime Video, and the lesson is not “microservices bad.” It is that for a workload that is essentially one tight data pipeline, paying a network hop and an object-storage write between every stage is paying the distributed tax for nothing. The right granularity was one process, and they had over-decomposed.

Shopify is the other pole — a company processing enormous transaction volume that runs, deliberately, on what they call the “majestic monolith,” a large Rails application. Their argument, articulated over years, is that a well-modularized monolith scales organizationally much further than the microservices orthodoxy claims, if you invest in real internal boundaries. Shopify built tooling to enforce module boundaries inside that monolith — componentization with explicit public interfaces and dependency rules — which is the modular monolith thesis stated by a company at a scale where everyone assumed microservices were mandatory. They have extracted services where it genuinely paid, but the core stayed a monolith on purpose. Between Prime Video walking back to a monolith and Shopify never leaving one, the through-line is the same: the unit of deployment should follow proven seams and real scaling needs, not fashion.


When microservices actually pay, and the migration path

Being fair to microservices means stating clearly when they win, because they do. Extract a service when there is a concrete, demonstrated reason, and the reasons are specific. Independent scaling of a hot path: if one module needs forty replicas and the rest need two, running them in one process wastes enormous capacity, and a separate service lets you scale just that path. Independent deploy cadence at organizational scale: when a team is large enough that sharing a release train with everyone else is the actual bottleneck, the autonomy is worth the tax. Genuinely polyglot needs: an ML inference path that must run a different runtime, or a component with a hard real-time requirement the host language cannot meet. True Conway boundaries: when an organizational seam is real and durable — a payments team with its own compliance scope and on-call — aligning a service to it reduces friction. Notice that every one of these is a demonstrated condition, not a speculative one, and none of them is “we read a blog post.”

The migration path that follows is the opposite of the rewrite era’s advice. Modularize first, extract second. Start as a modular monolith and make the internal boundaries real — explicit module APIs, enforced dependency rules, schema-per-module ownership. Run it. Watch where it actually hurts: which module is the scaling bottleneck, which boundary is the deploy bottleneck, which seam carries real organizational weight. Then, and only then, extract those specific modules — and because you already enforced clean boundaries and data ownership, the extraction is mechanical: wrap the in-process API in a network transport, give the schema its own database, point the caller at a client. You are extracting a proven seam, the only kind worth the network. The technique for carving pieces off incrementally is the strangler-fig pattern, and it works far better when the thing you are strangling already has internal seams to cut along. Martin Fowler named the corresponding default years ago: MonolithFirst. Build the monolith, find the boundaries empirically, extract only what earns it. The industry would have saved itself a decade of distributed monoliths if it had listened the first time.


Verdict

The modular monolith is the architecture most teams should default to, and the case for it is not nostalgia — it is arithmetic. It gives you the modularity that justified the microservices migration, enforced by your compiler and build instead of by the network, while keeping the one process, the real transactions, the stack-trace debugging, and the single deploy that microservices throw away. The distributed-systems tax — latency, partial failure, sagas, eventual consistency, tracing pipelines, service meshes, per-service CI — is real, itemizable, and entirely avoidable until you have a demonstrated reason to pay it. Microservices remain the right answer for independent scaling of proven hot paths, independent deploy cadence at genuine organizational scale, polyglot requirements, and durable Conway boundaries; outside those conditions they are a cost with no matching benefit, and the distributed monolith is what you get when you buy that cost on credit. Build the modular monolith first. Enforce the boundaries so it cannot rot into a ball of mud. Extract a service only when a specific module proves it needs to leave. Prime Video walked back, Shopify never left, and Fowler told everyone to do this in 2015. The rewrite-as-microservices article was wrong; the boring thing was right.


Sources

Comments