Modular Monolith: The Middle Ground Before Microservices
Microservices became synonymous with "serious architecture." If you don't have microservices, it seems like you're not doing things right. The result: teams starting with microservices from day one on projects that still haven't found product-market fit, paying brutal operational costs for flexibility they don't need yet.
There's a middle ground that solves the real problems of the traditional monolith without the operational complexity of microservices: the modular monolith.
What is a modular monolith
A modular monolith is a single deployable application that is internally organized with strong boundaries between domains. Each module has its own domain, its own business rules, and — in the strictest version — its own persistence. Modules communicate with each other through interfaces or internal events, never accessing each other's internals directly.
The difference from a traditional monolith is the level of discipline in internal boundaries. In a traditional monolith, any part of the code can call any other. In a modular monolith, modules are first-class citizens with defined internal APIs.
The difference from microservices is that everything runs in the same process. No network between modules, no serialization, no service discovery, no distributed failure management.
The problems it solves
The traditional monolith rots over time
The problem with the traditional monolith isn't that it's a monolith. It's that without design discipline, domain boundaries erode. Six months after launch, the Payments module calls the Users database directly, the Notifications module knows the implementation details of Orders, and changing anything requires understanding the entire system.
The modular monolith prevents this by design. Boundaries are explicit and verified in code review or, with tools like ArchUnit or dependency-cruiser, in the CI pipeline.
Premature microservices cost too much
Microservices solve real problems: large teams that need to deploy independently, domains with very different scale requirements, the need to use different technologies per service.
But they also introduce real complexity: you need orchestration (Kubernetes or equivalent), distributed observability (tracing, centralized logging), eventual consistency management, contracts between services, network failure handling. All of that has a permanent engineering cost.
If your team has 3 people and your product is in the validation phase, that cost probably isn't justified yet.
How it's structured
Each module has its own directory and exposes only what other modules need to know:
src/
users/
api/
users.service.ts
internal/
users.repository.ts
users.entity.ts
users.module.ts
orders/
api/
orders.service.ts
internal/
orders.repository.ts
payments/
The rule: nothing outside api/ is accessible from other modules. If OrdersService needs user data, it calls the public interface of UsersService, not the repository.
Persistence per module
The strictest version gives each module its own database schema. In PostgreSQL:
CREATE SCHEMA users;
CREATE SCHEMA orders;
CREATE SCHEMA payments;
Modules don't do cross-schema JOINs. If Orders needs the user's name, it gets it by calling the Users module at runtime. Cost: some queries require two calls instead of one JOIN. Benefit: when the time comes to extract Orders as a microservice, its schema is already isolated.
Communication between modules
Synchronous interface calls: OrdersService calls UsersService.findById() directly. Simple, traceable, fully typed. Works for most cases.
Internal events: Orders emits an OrderCompleted event and Payments listens. Decouples emitter and receiver. Good for flows where the emitter doesn't need an immediate response.
When it's better than microservices from day one
Small team (fewer than 10 engineers): the operational overhead of microservices consumes a significant fraction of the team's capacity.
Product in validation stage: the right domain boundaries aren't obvious at the start. Moving logic between modules is cheap. Changing boundaries between microservices means contract changes, data migrations, and cross-team coordination.
Domain with uniform scale requirements: if all domains have similar scale needs, scaling the entire monolith is simpler than scaling individual services.
No need for different technologies per domain: if you don't need Python for ML in one service and Go for another, the technological heterogeneity argument doesn't apply.
The open door to microservices
When the time comes to extract a domain as a microservice:
- The module already has its internal API defined → that API becomes the service's API.
- The schema is already isolated → the data migration is straightforward.
- Internal calls to the module → become HTTP calls or messages on a bus.
In a traditional monolith without boundaries, the same extraction first requires understanding and separating tangled dependencies. That's twice the work.
The real risk: maintaining boundaries
The modular monolith doesn't maintain itself. The main risk is gradual erosion of boundaries: someone accesses another module's repository directly "because it was faster," and six months later the boundaries are nominal.
Mitigations:
- Architecture review in code review: any import that crosses a module boundary is a review flag.
- Dependency analysis tools: dependency-cruiser (Node.js), ArchUnit (Java), NDepend (.NET) can fail the build if rules are violated.
- Explicit documentation of what's public and what's internal in each module.
Without team discipline, the modular monolith becomes a traditional monolith in 6 months. With discipline, it stays a healthy architecture for years.
Conclusion
The modular monolith isn't the architecture of those who couldn't do microservices. It's the right architecture for a specific stage: when the domain is still evolving, the team is small, and the operational cost of microservices isn't justified.
Done well, it gives you clear boundaries today and an orderly migration to microservices tomorrow. Done poorly, it's a traditional monolith with better-named folders.
The difference lies in maintaining the boundaries.