Engineering Atlas › System Design Fundamentals

When a monolith is the right call

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

One process, many capabilities

A typical monolith is a Rails, Django, Spring or Go application that serves the web UI, the public API and the admin screens, and runs background jobs from the same codebase. Scaling it means running more identical copies behind a load balancer, often with different process roles (web, worker, scheduler) started from the same artifact with different flags. Every copy can do everything, so capacity planning is one number: requests per second per instance.

Why teams choose it

In a new product nobody fully understands the domain yet. Moving a concept between modules in a monolith is an IDE refactor; between services it is an API migration across two pipelines. That is Martin Fowler's "monolith first" argument: you cannot draw good service boundaries until you know where they are (MonolithFirst). Chris Richardson's pattern catalogue lists the same forces from the other side: a monolith is simple to develop, test and deploy at first, and the costs (slow builds, one technology stack, scaling everything together) arrive as the team and codebase grow (microservices.io). You also get one debugger session, one stack trace across every layer, and transactions that commit or roll back as a whole.

Where it hurts

Problems grow with headcount, not traffic. Build and test times grow with the codebase. A broken test in billing blocks a deploy of search. A memory leak in report generation takes down checkout because they share a heap. Scaling is all-or-nothing: every instance carries every feature's code and memory.

The deepest problem is coupling. In one address space, nothing stops orders from reaching into billing's internal tables or private classes. Each shortcut is cheap on the day it is written, and after a few years every change touches everything. This is the multi-step consistency problem shape turned inside out: the monolith makes consistency easy, and the price is that everything becomes entangled with everything else.

The modular monolith

The answer that keeps the strengths is to enforce boundaries inside the deployable. Split the code into modules aligned to business capabilities (catalog, orders, billing), give each a small public API, declare which modules may depend on which, and make the build fail when code breaks the rules. Shopify did this with its Rails monolith and built Packwerk to check package dependencies statically; its public-API (privacy) checker was extracted into the packwerk-extensions project (Packwerk). When Shopify took stock in 2020, the monolith held over 2.8 million lines of Ruby in 37 components, and every component depended on more than half of the others. A runtime call-graph tool took over an hour per run and gave noisy results, so it switched to static analysis of constant references that runs in a few minutes and can block a pull request (Shopify Engineering). Java has the Java Platform Module System and ArchUnit tests; Go has internal/ directories that the go command refuses to import from outside the tree rooted at their parent (Go 1.4 release notes). Data gets the same treatment: each module owns its tables, and other modules read them only through its API.

A modular monolith is also the cheapest path to services later. When a module has a clean API and owns its data, extracting it with the strangler fig pattern is mostly plumbing. The same boundary discipline shows up in a second domain: a large frontend app split into feature folders with lint rules banning cross-feature imports has exactly this shape.

Down the stack

Layer 1The contract

Inside a monolith, a module call has function-call semantics: it either returns or throws, it never half-arrives, and it shares the caller's transaction, thread and deadline. A deploy is atomic from the code's point of view: every module is at the same version, so no API versioning is needed between them. The contract with operations is "one artifact, one rollback": reverting a release reverts every module together, which is both a safety net and a constraint, because you cannot roll back billing without also rolling back search.

Layer 2The mechanism

The monolith's moving parts are the module graph, the build, and the process model. The module graph is enforced by tooling: Packwerk reads package.yml files that list each package's declared dependencies and reports any constant reference that is not declared; ArchUnit expresses rules like "classes in ..billing.. may only be accessed by ..orders.api.." as unit tests (ArchUnit user guide); the go build tool rejects imports of another tree's internal package. The build caches per module (Bazel, Gradle build cache) so a change to billing reruns only billing's tests and its dependents. The process model is usually a pre-forking server: Puma or Unicorn boots the app once, then forks workers, and Gunicorn does the same for Python. A single database transaction can span modules because they share one connection: BEGIN, insert the order, decrement stock, write the ledger row, COMMIT.

Layer 3The machine

A direct function call costs about 1 to 5 ns; an in-process interface call a few ns more. The same call as an RPC inside one data centre costs 200 us to 1 ms once you add serialisation, a syscall per send and receive, kernel network stack, a round trip, and parsing. A request that touches 10 modules pays 10 ns in a monolith and 5 to 10 ms as services. Pre-forking saves memory through copy-on-write: a Rails app that occupies 600 MB after boot can fork 8 workers that share most of those pages, so the box needs perhaps 1.5 GB instead of 4.8 GB, until the garbage collector touches objects and the kernel copies the 4 KB pages it writes. Large heaps cost GC time: a JVM monolith with a 16 GB heap needs a concurrent collector such as G1 or ZGC to keep pauses in the low milliseconds. Each instance holds its own database pool, so 60 instances with 20 connections each means 1,200 connections, far above Postgres's default max_connections of typically 100 (Postgres docs), which is why big monoliths sit behind PgBouncer.

Why it's built this way

  1. Network calls are thousands of times slower than function calls and can fail halfway, so keeping collaborating code in one process is the cheapest and most reliable default.
  2. One process means one deploy, so the whole codebase moves together and teams share a release train.
  3. Shared address space makes every internal symbol reachable, so boundaries must be enforced by tools in the build, not by the runtime.
  4. Process memory is expensive, so monolith runtimes fork after boot and share pages copy-on-write, and pool database connections to protect the database.

Diagram

flowchart LR
  LB["Load balancer"] --> W1["Web process"]
  LB --> W2["Web process"]
  subgraph App["One artifact, one deploy"]
    W1 --> O["orders.api"]
    O --> B["billing.api"]
    O --> I["inventory.api"]
    B -.->|"blocked in CI"| II["inventory.internal"]
  end
  O --> DB[("Primary DB")]
  B --> DB
  I --> DB
  Q["Job worker (same artifact)"] --> B

Worked example

An online store runs a Django monolith: 300,000 lines, 45 engineers in 6 teams, 2,000 requests per second at peak. Each instance handles about 100 requests per second, so 20 web instances plus 6 job workers. Checkout touches orders, pricing, inventory, billing and notifications. In-process, the cross-module calls cost microseconds in total; the 120 ms checkout latency is almost all database time and the payment provider call. The whole checkout is one transaction, so a failed stock decrement rolls back the order and the ledger row with no compensation logic.

Real monoliths run at this scale and far beyond: GitHub's Rails application is nearly two million lines, more than 1,000 engineers work in it daily, and it deploys up to 20 times a day (GitHub blog).

The pain is elsewhere. CI takes 38 minutes because every change runs all 22,000 tests, 4 of last quarter's 11 rollbacks were caused by another team's change, and a memory leak in CSV export once took down checkout.

The team chooses a modular monolith instead of a split. Step one is a dependency map: a static scan finds 1,900 cross-module imports, 340 of them into another module's internals, and two cycles (orders and billing import each other; inventory, catalog and pricing form a triangle). They declare allowed dependencies, baseline existing violations so the build fails only on new ones, and burn the baseline down by about 30 a week. Module-aware test selection cuts median CI to 9 minutes, and CSV export moves to the job worker role, off the web heap. A year later inventory needs its own scaling profile; with a clean API and its own tables, extracting it takes one team six weeks.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Unstructured monolithFastest to start, any refactor is localCoupling grows unchecked, every change riskyPrototypes, very small teams
Modular monolithFunction-call speed, ACID across modules, enforced boundariesShared build, release train and runtimeMost products up to a few hundred engineers
Monolith with role-split processesIsolates jobs from web traffic, simple to adoptSame artifact, still one releaseBackground work hurting request latency
MicroservicesIndependent deploys and scaling per teamNetwork failures, distributed data, platform costMany teams with stable boundaries and real scaling differences
Distributed monolithNone worth havingNetwork costs plus lockstep deploysNever by choice; a sign boundaries are wrong

Pitfalls in production

Leadership lens

Check yourself

  1. Why can a monolith do in one transaction what microservices need a saga for?

    Show answer

    All modules share one process and one database connection, so the order insert, stock decrement and ledger write can sit between one BEGIN and COMMIT. Separate services own separate databases and can only coordinate with messages and compensation.

  2. What distinguishes a modular monolith from a big ball of mud, given that both deploy as one unit?

    Show answer

    Enforced boundaries: each module has a public API, owns its data and declares its dependencies, and the build rejects violations. The deployment model is the same.

  3. A pre-forked Rails app uses 600 MB per worker according to top, yet the host has far less memory than 8 times that. How?

    Show answer

    Workers share the parent's pages copy-on-write, so top counts shared pages in every worker. Only pages a worker writes, for example through garbage collection, become private copies.

  4. Why are dependency cycles between modules a warning sign even when everything works?

    Show answer

    A cycle means neither module can change, test or be extracted without the other, so they are one module in disguise. It also blocks module-aware test selection, because a change on either side affects both.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →