Why slicing a modular monolith by layer (controllers, services, repositories) gets expensive over time — and how to draw boundaries by business capability, with the Common Closure Principle, Conway's Law, and measurement via git history.
Splitting a modular monolith by layer (controllers, services, repositories) looks tidy and gets expensive. The boundary that survives is by business capability: what changes together, lives together. The formal name is the Common Closure Principle, from Robert C. Martin (2002). Conway's Law, from 1968, says the system's design mirrors the organization that builds it. Combine the two and git already tells you where your real code boundaries are today.
Most teams I've worked with, when the monolith starts to hurt, jump straight to microservices. I've been through this three times. In all three, the problem wasn't the monolith. It was the shape of the boundaries inside it.
Modular monolith is the buzzword. But the word hides what actually matters: where you draw the line between one thing and another. That decision outlives any framework choice you'll ever make.
What is a modular monolith?
A modular monolith is an application that still deploys as a single process while being internally cut into modules with explicit boundaries. Each module owns its domain, its code, and sometimes its own database schema. You keep the operational simplicity of a classic monolith without the cross-feature coupling.
The practical difference against the other common shapes:
| Trait |
Classic monolith |
Modular monolith |
Microservices |
| Deploy unit |
1 process |
1 process |
N processes |
| Module boundary |
Convention |
Explicit package or module |
Network (HTTP, gRPC) |
| Database |
1 shared schema |
1 database, per-module schemas |
1 database per service |
| Communication |
Direct in-memory call |
Direct in-memory call via interfaces |
Sync RPC or async events |
| Operational cost |
Low |
Low |
High (orchestration, observability, distributed failures) |
| Refactoring a boundary |
Painful (implicit coupling) |
Mechanical (move a folder) |
Expensive (data migration, coordinated deploy) |
| Right team size |
1 to 3 teams |
3 to 10 teams |
10+ teams with independent domains |
Most of what follows is about the middle column: how to draw the internal boundary that lets a modular monolith sustain itself for years.
The question nobody asks early
When a team decides to create a new module, the conversation almost always starts with nouns. "Let's make a products module." "This goes in the orders module." "We need a users module."
Nouns are easy. Nouns fit in a diagram. The problem is that nouns say nothing about what changes together.
The useful question is different: when something in this domain changes, who has to change with it? If the answer is "three different modules in three separate PRs," the boundary is wrong. You drew a line where none exists.
It has a name (and it's had one since 2002)
When I finally stopped to study why this pattern worked, I found out Robert C. Martin had already named it in 2002, in Agile Software Development: Principles, Patterns, and Practices. He called it the Common Closure Principle: classes that change together, for the same reasons, should live together. Classes that change for different reasons should live apart.
In other words: a good boundary isn't the one that looks pretty in the diagram. It's the one that minimizes the number of modules touched by a business change.
Worth pairing with Conway's Law, from 1968: the design of a system mirrors the design of the organization that builds it. If your team is split by capability (payments, catalog, search), sustainable code is split by capability too. When the team changes, the design has to change with it. When the design doesn't keep up, it hurts.
Nothing in those two texts is new. What's new is how many teams in 2025 still organize code as if neither idea existed.
A case that stuck with me
A few years ago I inherited a checkout system that had been cut by layer. The root tree looked like this:
src/
controllers/
CheckoutController.ts
PaymentController.ts
OrderController.ts
ShippingController.ts
services/
CheckoutService.ts
PaymentService.ts
OrderService.ts
ShippingService.ts
repositories/
CheckoutRepository.ts
PaymentRepository.ts
OrderRepository.ts
dtos/
...
entities/
...
Each folder with about a hundred files per feature. Pretty in description. Painful in practice.
Adding a field to the payment form meant touching eight files across seven folders: a DTO, an entity, a repository (and migration), a service, a controller, two tests, and a central validation file. The team had automated part of the work with editor snippets. They thought it was productivity. It was anesthesia.
When I reorganized by what changes together, what was left looked like this:
src/
checkout/
http/
domain/
persistence/
tests/
payment/
http/
domain/
persistence/
tests/
shipping/
http/
domain/
persistence/
tests/
shared/
logging/
db/
http-kernel/
Each top folder is a business capability. Inside it, everything that makes that capability exist. Adding that same field now touched two files, in the same directory. Code review stopped requiring a split editor.
I didn't invent anything. I just stopped organizing by what things look like and started organizing by what changes together.
How to measure before refactoring
What's missing from most boundary conversations is that the right boundary can be discovered with data, not just intuition. You already have the data: it's in git.
The simplest heuristic: files that change in the same commit, repeatedly, over months, are cohesive. Git knows this. You just have to ask.
# Most-changed files over the last 6 months,
# ranked by how many commits touched them.
git log --since=6.months.ago --name-only --pretty=format: \
| awk 'NF' \
| sort | uniq -c | sort -rn | head -30
That already tells you which files are hot. To find coupled pairs (the part that matters), the honest way is to walk commit by commit. Something along these lines works:
# Lists, for each commit over the last 6 months, the files
# touched. Good for manual grep of suspicious pairs.
git log --since=6.months.ago --name-only --pretty=format:'---%H' \
> /tmp/commits.txt
For something more serious, use code-maat, by Adam Tornhill. It's the tool behind Your Code as a Crime Scene and Software Design X-Rays. It computes temporal coupling: the probability that two files change together. CSV output, easy to rank. Pairs with 80%+ temporal coupling across different modules are the cheapest evidence you'll ever get of a wrong boundary.
# Simplified usage (after generating the log in its format).
maat -l logfile.log -c git2 -a coupling \
| sort -t, -k3 -rn | head -20
I run this before any large rewrite. In half an hour, the repository tells you where the real boundaries of the system are. Refactoring becomes honest: you're fixing something the history has already proved broken.
Why splitting by layer feels right
I get the appeal. Splitting by layer is symmetric, it teaches clean architecture at a glance, and it gives you the comforting feeling that every file has its place.
The problem is that the symmetry is visual, not behavioral. You get a pretty diagram and lose cohesion. A team that splits by layer will, over time, open larger and larger PRs, because every real change crosses horizontal boundaries.
And a big PR isn't just slow. A big PR is where bugs hide.
Three questions before creating a new module
When I draw or revisit a boundary, I walk through three questions. In this order.
Who owns that decision? If more than one team makes calls about the same module, either the boundary is in the wrong place or the team is. One of the two is going to change.
Who needs to know when this changes? If the answer is "everyone in the engineering channel," the module is in charge of something too central for its size. Time to make the contract explicit. Or to split it into two things with honest names.
If I delete this module tomorrow, what breaks? Healthy answer: a single, clear capability disappears, with predictable symptoms. Bad answer: sixteen places stop working for different reasons. That's hidden coupling. The boundary exists in the diagram, not in the code.
When the rule doesn't apply
It doesn't work everywhere. Pure utilities (date formatting, currency parsing, string helpers) have no domain of their own. Force them into a module and you're only adding indirection.
Platform code escapes too. Logger, database client, tracing instrumentation. That kind of thing crosses every module by design. Treating it like a business module just gets in the way. That's what lives in shared/ in the example above, and the less that shows up there, the better.
Edge integrations deserve care too. A Stripe webhook isn't "the Stripe module." It's part of the module that receives payment, translated at the edge. The boundary sits between what's your model and what's their contract.
The six-month test
The most useful part of this approach only shows up later. Six months, a year. You open the repo to touch something you haven't seen in a while, and the question is simple: does it still hurt?
If it does, the boundary drifted. Probably because the business changed and no one updated the design. It happens. Refactoring a boundary is normal maintenance, not a failure of the past. Run git log again and see where the coupling has moved.
If it doesn't, you did the right thing. Not because you guessed the future, but because you drew by what changes together, and that stayed true after the team forgot why it had been done that way.
Modular monolith isn't a pattern. It's the consequence of taking that question seriously from the first commit.
Frequently asked questions
Is a modular monolith the same as microservices?
No. Microservices run in separate processes that talk over the network. A modular monolith runs in a single process. The similarity is only internal: both force explicit boundaries between capabilities. The operational cost is completely different.
When does it make sense to migrate from a modular monolith to microservices?
When one capability has a real need for independent scaling, a distinct deploy cycle, or a dedicated team. Do not migrate because of fashion. Each microservice carries permanent cost of observability, deploys, and cascading failures. That cost is worth paying only when the capability no longer fits inside the monolith.
How do I start reorganizing an existing monolith by capability?
Start by measuring. Run git log over the last six months and rank files that change together often. Those pairs point to implicit capabilities that the current architecture cuts in half. Use code-maat if you want to go beyond manual git log. Reorganize one module at a time, in PRs smaller than 500 lines.
Does a modular monolith work with any language?
Yes. It is not a framework pattern, it is an organization pattern. The examples in this article use TypeScript, but the same capability split works in Go, Java, .NET, Python or Ruby. What changes is the tool that enforces the boundary (packages, modules, namespaces), not the rule.
Further reading
These are the texts I point people to when they want to dig deeper. All of them back, from some angle, the thesis above.
- Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (Prentice Hall, 2002), chapters on Package Cohesion Principles. Restated in Clean Architecture (2017), chapter "Component Cohesion". The formal name for what this article defends.
- Melvin Conway, How Do Committees Invent? (1968). The original Conway's Law paper, still surprisingly short.
- Martin Fowler, MonolithFirst (2015). Why microservices-first is almost always premature.
- Adam Tornhill, Your Code as a Crime Scene (2024, 2nd ed.). Everything you can find out by reading git history instead of guessing.
- Kirsten Westeinde, Deconstructing the Monolith (Shopify Engineering, 2019). How one of the largest monoliths in production was reorganized by capability, not by layer.
- DHH, The Majestic Monolith (2016). The short, honest defense of a well-designed monolith.
- Kamil Grzybek, Modular Monolith: A Primer (2019). Technical series in .NET, with stack-agnostic ideas.
Enable JavaScript for the full experience.