MonolithSCALE-SPECIFICCONTESTEDLIFETIME-SPECIFIC

Designing a Monolith

A monolith is a deployment decision, not a structural one. It can be well designed, it usually should be where a team starts, and conflating it with "big ball of mud" costs teams years.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

If a monolith is one deployable unit, what does good design inside it actually consist of?

The requirement

A four-person team is building a new product. An advisor has told them to start with services "so it scales", and the first sprint has produced three repositories, a message broker and no features.

The obvious build

Split into services from the start. It is harder to split later, distributed systems are the endgame anyway, and starting monolithic just means paying to migrate.

Why it breaks

The premise about "harder later" is backwards in the case that matters. Splitting a well-modularised codebase later is mechanical; splitting one whose boundaries were guessed before the domain was understood means splitting along the wrong lines and then moving them across a network (Where the Boundary Goes).

How it breaks as requirements change
  • The premise about "harder later" is backwards in the case that matters. Splitting a well-modularised codebase later is mechanical; splitting one whose boundaries were guessed before the domain was understood means splitting along the wrong lines and then moving them across a network (Where the Boundary Goes).
  • Four engineers now operate three deployments, three pipelines, three sets of alerts and a broker, and none of that work produces a feature. Operational cost is per-service and does not scale down (When Design Does Not Pay).
  • Every cross-service change becomes a versioned contract change and a coordinated release, which for a domain that is still moving is the dominant cost (Backward Compatibility as a Constraint).
  • The transaction is gone. A rule that spans two of the three services now needs a saga, compensations and an eventual-consistency story, for a system with no scale reason to have paid for any of that (Distributed Transactions in Architecture covers the machinery).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • Four engineers, one of whom is part-time, and no dedicated operations capacity.
  • The domain is genuinely unknown — the product will change shape at least twice before the boundaries anybody draws today are correct (Requirements Are a Snapshot).
  • Load is currently zero and will be modest for at least a year, so nothing about this decision is driven by scale.
Invariants
  • Every business rule is enforced on every path into the system, and a new entry point cannot bypass one (Enforcing Invariants).
  • A change that must be atomic stays atomic. A single database transaction is a capability, not an accident, and giving it away should be a decision (Consistency Boundaries).

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The monolith owns being one deployable, one process and one transaction boundary. Those are its advantages and they should be used deliberately, not apologised for.
  • Modules inside it own their domains, exactly as services would — the internal structure question is identical and is not made easier or harder by the deployment shape (The Modular Monolith).
  • The team owns keeping boundaries honest without a network to enforce them, which is the one genuinely harder thing about a monolith (Internal Module Contracts).
Boundaries
  • The deployment boundary is one process. The design boundaries are internal, and there can be as many as the domain justifies — the two are independent, and treating them as the same question is the error the whole module addresses.
  • The seam that matters most is around data ownership: which module may read and write which tables. That is the seam a future service split would need, and it is free to establish now (State Ownership).
  • Boundaries in a monolith are cheap to move, which is their most under-rated property. Being wrong about a module boundary costs a refactor; being wrong about a service boundary costs a migration (Reversible and Irreversible Decisions).

Deployment shape and internal structure are two different axes

The word "monolith" is used to mean two unrelated things: one deployable unit, and code with no internal boundaries. Keeping them separate makes the space obvious — there are four quadrants, not two options, and the two that dominate real systems sit diagonally opposite each other.

The row worth studying is the fourth. A distributed monolith has every coupling problem of an unstructured codebase plus network calls, versioned contracts and coordinated releases, and it is what a service split produces when the boundaries were guessed. It is the reason "start with services" is riskier advice than it sounds (The Distributed Monolith: All of the Cost, None of the Autonomy).

  • The two axes are independent. Choosing one deployable says nothing about whether the code inside is disciplined.
  • Moving down the internal-boundary axis is always available and is usually the cheaper improvement (Extract Module).
  • Moving right on the deployables axis without first moving down the boundary axis produces row four, reliably.
  • Most teams arguing about monolith versus services are actually arguing about internal structure, and the deployment shape is not the variable (Design, Architecture and System Design).
ShapeDeployablesInternal boundariesCost of a cross-cutting changeCharacteristic failure
Structured monolithOneReal: owned data, declared interfaces, enforced importsOne commit, one deploy, compiler-checked, one transactionBoundaries erode quietly because nothing but a CI check prevents an import; and every team shares one release cadence
Big ball of mudOneNone — any code reaches any tableA grep, a guess, and a full regressionChange amplification: the tenth feature costs five times the first, and nobody can say why (Change Amplification)
Well-bounded servicesManyReal, and enforced by the networkSeveral PRs, a contract version, an ordered releaseOperational cost per service; and a change that spans services needs a saga where a transaction would have done
Distributed monolithManyNone — services share tables and call each other synchronously in chainsEvery service, in a specific order, with a rollback planEvery coupling problem of the mud ball, plus partial failure, plus latency, plus versioning (Partial Failure)

What the four-person team actually gains, and what it gives up

Price the decision on a change that is normal for a young product: a rule that spans several parts of the system, arriving in a domain that is still being understood. That is the change this team will make dozens of times in the first year, and it is where the deployment shape shows up most sharply.

One requirement, two deployment shapes, four engineers
The change

"When a trial converts to paid, apply the referral credit, start the billing cycle, and notify the referrer." It spans accounts, billing and notifications — and two weeks later the definition of "converts" changes.

Three services with a broker, split in month one
accounts-servicebilling-servicenotifications-serviceshared contract packagebroker topic definitions
tests3 service test suitescontract tests between each pairan end-to-end environment that must be stood up
5 modules · 3 test files

Three PRs in three repositories, a contract version bump, a release order (billing before accounts, or the message is rejected), and a compensating action for the case where the credit applies but the cycle fails to start. The definition change two weeks later repeats all of it. Roughly a week each time, most of it coordination.

One deployable with three internal modules that own their data
accounts/billing/notifications/
tests3 module test suites through public interfaces1 end-to-end test of the conversion flow
3 modules · 2 test files

One commit, one transaction covering credit and cycle start, the notification queued after commit. The compiler verifies every call site of the changed interface. The definition change two weeks later is a one-line edit with a compiler-verified blast radius. Hours, not days.

what it cost The monolith bought this by giving up isolation and independent release. When notifications has a bug, the fix ships the whole system, and a runaway query in billing degrades account creation — neither of which is true in the split version. It also gave up the forced discipline: the three modules can reach into each other with an import and nothing stops them, so the boundary that the network would have guaranteed now depends on a CI check somebody has to maintain (Internal Module Contracts). For four engineers that trade is clearly right; the point is that it is a trade and it has a direction of travel.

When splitting is the right answer

SCALE-SPECIFICEvery option above is triggered by something that appears with growth in team count or in load, which is why this decision has no answer at four engineers and an obvious one at fifty. The specific threshold differs by organisation — deployment tooling maturity moves it by a factor of two or three — so the trigger to write down is the constraint itself, not a headcount.

None of this is an argument that monoliths are always correct. It is an argument that the split should be triggered by a named constraint rather than by a growth stage, and the named constraints are surprisingly few. Each of them is about isolation or independence — not about size, and not about the code being unpleasant.

Notice that "the codebase is large" and "the code is a mess" appear on neither list. Both are real problems and neither is addressed by a deployment change; a mess distributed across services is still a mess, and now it has a network in the middle of it (Refactor or Rewrite).

Should this module become a separate deployable?

The monolith is working. What would actually justify splitting a piece out?

A team needs to deploy independently

when Enough teams share one release train that shipping requires coordination, and that coordination is now the delivery bottleneck. This is the most common legitimate reason and it is organisational.

cost A versioned contract between the two, forever, plus a second pipeline, alerting and on-call. Cross-boundary changes become two-phase (Versioned Interfaces).

A genuinely different scaling or resource profile

when One component is CPU-bound, or needs GPUs, or has traffic two orders of magnitude above the rest, so scaling the whole deployable to serve it is wasteful.

cost Network latency on every call that used to be in-process, and partial failure where there was none (What Changes at the Network Boundary).

A compliance or blast-radius boundary

when A component handles data whose access must be provably restricted, or whose failure must not be able to take the rest down.

cost Data that used to be joined must now be composed by the caller, and the transaction that spanned both is gone (Least Privilege as a Design Decision).

A different lifecycle or technology need

when A component must be written in another language, or has an upgrade cadence the rest cannot follow — an ML runtime, a legacy adapter pinned to an old dependency.

cost A second stack to build, test, deploy and hire for, which is the cost teams most consistently underestimate (Dependency Management).

None of the above — improve the module boundary instead

when The complaint is that the code is coupled, hard to test or hard to understand. That is an internal structure problem and splitting will carry it across the network intact.

cost The work is unglamorous and produces no architecture diagram, which is a real reason teams choose the split instead (The Modular Monolith).

How to build it

Most important first.

  • Start with one deployable and take the internal structure seriously from the first week. The discipline is the same discipline services would require, minus the operations (Package by Feature).
  • Organise around the domain, not around technical roles, so that a feature change lands in one directory rather than four (Vertical Slices).
  • Give each module its own data and require others to go through its interface, because that is the constraint that a service split would impose and it costs almost nothing to adopt early (Internal Module Contracts).
  • Use the transaction. A monolith's ability to make a multi-table change atomically is a genuine correctness advantage over the distributed alternative and should be spent where invariants demand it (Consistency Boundaries).
  • Write down what would make you split — a team that needs independent deploys, a component with a genuinely different scaling profile, a compliance boundary — so the decision has a trigger rather than a mood (Revisit Triggers).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Adding a feature that spans three modules: one commit, one build, one deploy, one transaction, and the compiler verifies every call site. Hours. Under three services the same change is three PRs, a contract version, an ordered release and a rollback plan — days at best.
  • Changing an internal interface: mechanical, compiler-checked, one commit. This is the monolith's single largest advantage and it applies to the change teams make most often while a domain is still moving.
  • What is genuinely more expensive: deploying a fix to one module means deploying everything, so the blast radius of any release is the whole system; and one module's performance problem is everyone's. Those two costs are real and they are what eventually justifies a split.
What the recommended approach costs
  • One deployment means one blast radius and one release cadence. Every team ships together, and one bad change blocks everybody.
  • No process isolation: a leak, a runaway query or a crash is shared. Services buy containment, and that purchase is real.
  • A single technology stack for everything, which is usually a benefit and occasionally a genuine constraint.

What can go wrong

Failure modes
  • Boundaries erode, because nothing prevents an import. Within eighteen months every module knows every other one and the monolith has become the thing people mean by the word (Dependency Cycles).
  • The database becomes the integration point: any module queries any table, which is the coupling that makes a later split genuinely hard (The Shared Database: An Honest Trade, Not a Prohibition).
  • One module's resource use — a report that loads a million rows — degrades everything, and there is no isolation to fall back on (N+1 as a Design Problem).
  • The mitigation fails in a familiar way: a team adopts module boundaries but enforces them only by review, so the boundaries hold precisely as long as the person who cares about them stays (Bus Factor).
Dependencies, and their direction
  • Everything shares one runtime, one dependency tree and one deployment. An upgrade of a shared library affects every module at once, which is the clearest genuine downside (Transitive Dependencies).
  • Module-to-module dependencies are compile-time and therefore checkable, which is a large advantage over the runtime-only checking a service split gives you (Dependency Cycles).
  • The whole system depends on one process's availability. A memory leak in a background job takes the API with it, and that is a real cost of the shape (Containment Is Decided by What Is Shared, Not by Where the Service Boundaries Are in Distributed Systems covers containment properly).
Misreads
  • "Monolith means unstructured." A monolith is one deployable. Structure is an independent axis, and the strongest counterexample is the distributed monolith: many deployables, no structure, plus a network (The Distributed Monolith: All of the Cost, None of the Autonomy).
  • "Start with a monolith, then split" means the split is inevitable. It is not. Most systems never need it, and treating it as a scheduled milestone produces splits made for their own sake (YAGNI, With Its Bill Attached).
  • "Services force good boundaries." They force *some* boundary, drawn when you knew least, and enforce it with a network rather than a compiler. A wrong boundary enforced harder is worse, not better (Premature Abstraction).
  • "Monoliths cannot scale." Vertical scaling and horizontal replication of a stateless monolith carry most products a very long way; the scaling constraint is almost always the database, which a service split does not by itself address (Horizontal vs Vertical Scaling in Architecture has the numbers).
Smells this explains
  • god-object
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Test modules through their public interface, not their internals, so the interface is exercised and the internals stay free to move (What a Unit Is).
  • Keep a small set of end-to-end tests over the whole deployable — cheap in a monolith, and they cover the wiring that module tests cannot (Where a Test Must Be Real).
  • Add an architecture test asserting which modules may import which. In a monolith this is the only thing standing in for the network, and it takes an afternoon (Stable Dependencies).
  • Assert data ownership in tests too: a module's tests should fail if it queries a table it does not own, which is enforceable with a schema-per-module and a restricted connection (Internal Module Contracts).
How this design ages
  • A well-structured monolith usually survives far longer than teams expect — well past the point where conventional advice says to split — because most of the pressure to split is organisational rather than technical (Design, Architecture and System Design).
  • The first genuine split is normally driven by a team boundary or a distinct scaling profile, and when it comes, a module with owned data and a declared interface is extracted in weeks rather than quarters (The Strangler Pattern).
  • The shape stops working when deployment coordination becomes the bottleneck: enough teams sharing one release that nobody can ship independently. That is a people-count threshold, not a line-count one.

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • SCALE-SPECIFICThe recommendation flips on team count, not on traffic. Below roughly two teams, coordination is free and one deployable is clearly cheaper. Above five or six teams sharing one release train, the coordination cost of a single deployment dominates and splitting starts paying regardless of how well structured the code is. Advice from either side of that line reads as absolute and is not.
  • CONTESTEDThe strongest case for starting with services: boundaries that are merely conventional decay under delivery pressure, and a network boundary is the only one that reliably holds — so paying the operational cost early buys a structure that survives its advocate leaving. That is a genuine observation about organisations rather than about code, and it is right that internal boundaries erode. Where it is weakest is timing: it enforces boundaries at the moment you understand the domain least, and a wrongly placed boundary made unbreakable is more expensive than a well-placed one that needs occasional defending.
  • LIFETIME-SPECIFICFor a system with a known short life or an experimental product likely to be discarded, even module boundaries are overhead and one directory is the honest answer. The argument for internal structure assumes the code must keep absorbing requirements for years; it does not apply to a thing that will be deleted after the pilot.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — the scaling arguments usually offered for splitting belong there, and most of them turn out to be about the database rather than about the application process.
  • Testing & Reliability Engineering — one deployable means one blast radius, so a monolith's release safety depends much more heavily on progressive delivery and fast rollback than a split system's does.