Modules, Not Layers
Organize by feature/domain module (vertical slices) rather than technical layers (controllers/, services/, repos/) - so a change lives in one place and modules can become services later.
On this page
If a modular monolith's power is its modules, the first question is: what is a module? The instinct from years
of tutorials is to organize by technical layer - a controllers/ package, a services/ package, a
repositories/ package. That's the wrong axis. A modular monolith organizes by feature or domain - a
billing/ module, a catalog/ module, each containing its own controllers, services, and repositories. This
single reorientation is what makes boundaries real.
Layers vs. modules: the two axes
Consider ledger-legacy's package structure, the classic layered layout:
com.ledger
βββ controllers/ β LedgerController, AccountController, ReportController, FeeController...
βββ services/ β LedgerService, AccountService, ReportService, FeeService...
βββ repositories/ β LedgerRepo, AccountRepo, ReportRepo, FeeRepo...
βββ models/ β everythingTo add a "disputes" feature you touch every folder, and everything in services/ can call everything else - no
boundaries at all. Now the same code organized by feature module:
com.ledger
βββ accounts/ β AccountController, AccountService, AccountRepository, Account
βββ transactions/ β TransactionController, TransactionService, ..., Transaction
βββ fees/ β FeeController, FeeService, FeePolicy...
βββ reporting/ β ReportController, ReportService...
βββ shared/ β truly cross-cutting typesEach module is a vertical slice owning its whole stack for one capability. Adding "disputes" means adding one
disputes/ module - a change lives in one place. And critically, a module is now a candidate to become a
microservice later; a layer never is.
Why vertical beats horizontal
Organizing by feature has concrete advantages the layered layout can't offer:
- Change locality (high cohesion). A feature's code lives together, so a change touches one module instead of fanning across four technical folders. You navigate by what the code does, not what kind of code it is.
- Boundaries you can enforce. "The
reportingmodule may not touchfees' internals" is a meaningful, checkable rule. "Controllers may not touch repositories" is a weak layering guideline that says nothing about which feature is coupling to which. - Screaming architecture. The top-level packages announce what the system does (accounts, transactions, fees) - not what framework it uses (controllers, services). A newcomer sees the business capabilities at a glance.
- A migration path. Because a module is a self-contained capability, extracting it into a service (later lesson) is tractable. You can't extract "the services layer" into a service.
Layers still exist - inside a module
This isn't 'layers are bad.' Within the billing module you'll still have a controller, a service, and a repository - horizontal layers are a fine way to organize the insides of a module. The point is which axis is the TOP-LEVEL organizing principle. Feature-first at the top, layered within: billing/ contains its own thin controller/service/repository, and so does catalog/. You get both, in the right order.
Drawing module boundaries
Where do modules come from? From the domain - specifically, the bounded contexts of the DDD module. A
bounded context is a natural module boundary: billing, catalog, shipping are contexts and modules. Look
for the same signals: where the language shifts, where different rules and change-rates cluster, where a subset
of the data is cohesive. Good module boundaries are just bounded contexts realized in package structure - which
is exactly why they later make clean service boundaries too.
Imagine a department store organized by material - one floor for everything wooden, one for everything metal, one for everything fabric. To buy a complete outfit you'd trek across three floors, and 'the fabric floor' means nothing to a shopper. That's layering. Real stores organize by department - menswear, electronics, homeware - each a vertical slice with its own displays, staff, and stockroom. You find everything for one need in one place (change locality), each department has a clear identity and boundary, and if electronics outgrows the store you can spin it off into its own shop (extract a service). Organizing software by feature module is choosing departments over floor-materials.
ledger-legacy is organized as controllers/, services/, repositories/, models/. Product wants a new 'fraud detection' capability that scores transactions and flags accounts. Describe how you'd add it in a layered structure versus a feature-module structure, and explain which makes the boundary enforceable and the future extraction possible.
What's the top-level organizing principle of a modular monolith?
Key takeaways
- Organize the monolith top-level by feature/domain module (vertical slices), not by technical layer - each module owns its own controller, service, and repository.
- Feature-first gives change locality (a change lives in one module), enforceable boundaries, and 'screaming architecture' that announces what the system does.
- Layers aren't wrong - they organize the inside of a module; the point is which axis is the top-level organizing principle.
- A feature module can later be extracted into a microservice; a technical layer never can.
- Module boundaries come from the domain's bounded contexts - the same seams that make good service boundaries later.