Start Learning
Javaneer
Back to stage
Stage 4Β·The Modular Monolith

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.

14 min readIntermediate
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/        ← everything

To 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 types

Each 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 reporting module may not touch fees' 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.

A department store: by department, not by floor-material

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.

Re-slice the package structure

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.
Was this lesson helpful?
Edit this page on GitHub