Loading learning content...
The industry often presents architecture as a binary choice: monolith or microservices. Pick your poison—spaghetti code or distributed complexity. But this is a false dichotomy. There exists a third path, increasingly recognized as the pragmatic default for most organizations: the modular monolith.
A modular monolith combines the deployment simplicity of a monolith with the structural discipline of microservices. It deploys as a single unit but is internally organized into well-bounded modules with explicit dependencies—like microservices that happen to run in the same process.
This architecture has been quietly powering successful products for years. Shopify's Ruby on Rails monolith, despite handling enormous scale, is modular. Basecamp explicitly advocates for this approach. Many organizations discover it independently when they realize pure microservices are premature but their monolith needs structure.
By the end of this page, you will understand what defines a modular monolith, how to establish and enforce module boundaries, the techniques that make modular monoliths work, their genuine advantages and limitations, and when this architecture is the optimal choice.
A modular monolith is a single deployable application organized into distinct, well-bounded modules, each representing a specific domain or business capability. Unlike a traditional monolith where code tends to entangle, a modular monolith enforces explicit boundaries and controlled dependencies between modules.
The key principles:
Visualizing the Difference:
Traditional Monolith (Big Ball of Mud):
┌─────────────────────────────────────────────────────────────┐
│ Code tangled together without clear boundaries │
│ - Direct database access from anywhere │
│ - Cross-cutting dependencies everywhere │
│ - Changing one thing risks breaking unrelated things │
└─────────────────────────────────────────────────────────────┘
Microservices:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Service │ │ Service │ │ Service │ │ Service │
│ A │ │ B │ │ C │ │ D │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │
[ DB ] [ DB ] [ DB ] [ DB ]
--- Network calls between services ---
Modular Monolith:
┌─────────────────────────────────────────────────────────────┐
│ Single Deployment Unit │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Module │ │ Module │ │ Module │ │ Module │ │
│ │ A │→→ │ B │ │ C │ ←←│ D │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ ↓ ↓ ↓ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Shared Database, Partitioned Schema │ │
│ │ [ A tables ][ B tables ][ C tables ][ D tables ] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ --- In-process calls via module public APIs --- │
└─────────────────────────────────────────────────────────────┘
| Aspect | Traditional Monolith | Modular Monolith | Microservices |
|---|---|---|---|
| Deployment unit | Single (entangled) | Single (structured) | Multiple independent |
| Internal boundaries | None or weak | Strong and enforced | Process boundaries |
| Communication | Any-to-any calls | Via module APIs | Network calls |
| Data access | Any code accesses any table | Module owns its tables | Separate databases |
| Transaction model | ACID within app | ACID within app | Eventual consistency |
| Extraction difficulty | Hard (entangled) | Designed for it | N/A (already separated) |
A modular monolith is what microservices would look like if you removed the network boundary. The discipline is the same—clear ownership, explicit interfaces, encapsulated data. The absence of network calls is what makes it simpler to operate.
The value of a modular monolith depends entirely on the quality of its boundaries. Poor boundaries create a distributed monolith inside your monolith—the worst outcome.
Domain-Driven Design (DDD) for Boundaries:
The best source of module boundaries is bounded contexts from Domain-Driven Design. A bounded context represents a specific area of your domain with:
Classic bounded contexts in e-commerce:
Identifying Boundaries in Practice:
Event Storming — Gather domain experts and map the business process. Where do handoffs occur? Where does language change? Those are boundary candidates.
Team Topologies Analysis — Where are your team boundaries? Modules should align with teams (Conway's Law, intentionally applied).
Change Frequency Analysis — What code tends to change together? Group frequently co-changing code into modules.
Dependency Analysis — What code depends on what? Circular dependencies suggest boundaries are wrong.
Language Shifts — When the same word means different things (e.g., 'Product' in catalog vs. inventory vs. ordering), you've found context boundaries.
Wrong boundaries are expensive to fix—they require significant refactoring. But in a modular monolith, at least refactoring is a code change. In microservices, wrong boundaries require re-architecting infrastructure. This is why modular monolith first is safer: you can discover correct boundaries before committing to service extraction.
Boundaries exist only if they're enforced. Without enforcement, modules degrade into a traditional monolith over time as developers take shortcuts. Enforcement must be automated—relying on discipline alone fails at scale.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// Using ArchUnit to enforce module boundariesimport com.tngtech.archunit.core.domain.JavaClasses;import com.tngtech.archunit.core.importer.ClassFileImporter;import com.tngtech.archunit.lang.ArchRule;import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; public class ModuleBoundaryTest { private final JavaClasses classes = new ClassFileImporter() .importPackages("com.company.ecommerce"); @Test void orderingModuleDoesNotDependOnPaymentInternals() { ArchRule rule = noClasses() .that().resideInAPackage("..ordering..") .should().dependOnClassesThat() .resideInAPackage("..payments.internal.."); rule.check(classes); } @Test void modulesOnlyAccessOwnDatabaseRepositories() { ArchRule rule = classes() .that().resideInAPackage("..inventory..") .should().onlyAccessClassesThat() .resideInAnyPackage( "..inventory..", // Own module "..shared..", // Shared kernel "java..", // JDK "org.springframework.." // Framework ); rule.check(classes); } @Test void noCircularDependenciesBetweenModules() { ArchRule rule = slices() .matching("com.company.ecommerce.(*)..") .should().beFreeOfCycles(); rule.check(classes); }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// File structure showing explicit public/internal separation// Each module exposes a clear public API // /src/modules/ordering/index.ts — PUBLIC APIexport { OrderService } from './api/order-service';export { OrderDTO, CreateOrderRequest } from './api/dto';export { OrderEvents } from './api/events';// Note: Internal implementations NOT exported // /src/modules/ordering/internal/order-repository.ts — INTERNAL// This file is NOT exported from the module// Only accessible within the ordering moduleexport class OrderRepository { // Direct database access - internal concern} // /src/modules/ordering/api/order-service.ts — PUBLIC INTERFACEimport { OrderRepository } from '../internal/order-repository'; export class OrderService { private orderRepo: OrderRepository; // Public method - other modules can call this async createOrder(request: CreateOrderRequest): Promise<OrderDTO> { // Implementation uses internal repository const order = await this.orderRepo.create(request); return this.toDTO(order); } // Public method - other modules call this to get order data async getOrder(orderId: string): Promise<OrderDTO | null> { const order = await this.orderRepo.findById(orderId); return order ? this.toDTO(order) : null; }} // tsconfig.json paths to enforce boundaries{ "compilerOptions": { "paths": { "@ordering": ["src/modules/ordering/index.ts"], "@payments": ["src/modules/payments/index.ts"], "@inventory": ["src/modules/inventory/index.ts"] // Note: No path mapping to internal directories // Importing '@ordering/internal/*' is not possible } }}The best enforcement makes violations impossible or obviously wrong. If accessing another module's internals requires disabling a linter, bypassing access modifiers, or adding special configuration, developers will instinctively avoid it. Make the right thing easy and the wrong thing hard.
Modules must communicate, and how they communicate determines whether the modular monolith maintains its structure or degrades into coupling.
Pattern 1: Direct Method Calls (Synchronous)
The simplest pattern: one module calls another module's public API within the same process.
// In OrderService (ordering module)
async createOrder(request: CreateOrderRequest) {
// Direct call to inventory module's public API
const available = await this.inventoryService.checkAvailability(
request.items
);
if (!available) throw new InsufficientInventoryError();
// Continue with order creation
}
Pros: Simple, type-safe, transactional Cons: Creates compile-time coupling, caller blocked during execution
Pattern 2: In-Process Events (Asynchronous)
Modules communicate through domain events, dispatched and handled in-process.
// OrderService emits event
await this.eventBus.publish(new OrderPlacedEvent(order));
// InventoryModule subscribes (in same process)
@Subscribe(OrderPlacedEvent)
async handleOrderPlaced(event: OrderPlacedEvent) {
await this.reserveInventory(event.orderId, event.items);
}
Pros: Loose coupling, modules unaware of each other, extensible Cons: Harder to trace, event ordering complexity, still same transaction scope by default
Pattern 3: Async Events with Outbox (Preparation for Extraction)
For true decoupling, events are persisted to an outbox table and processed asynchronously.
// Order creation and event in same transaction
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ ...orderData });
await tx.outboxEvent.create({
type: 'ORDER_PLACED',
payload: order,
processedAt: null
});
});
// Background processor polls outbox, dispatches events
Pros: True async delivery, resilient, extractable to message broker later Cons: Eventually consistent, more complex implementation
In a modular monolith with a shared database, you CAN use ACID transactions across modules. This is a major advantage over microservices. However, if you anticipate extraction, design as if you couldn't—use events where possible to minimize transaction scope.
Data ownership is where modular monoliths differ most from microservices. You typically share a database but partition it logically by module.
| Access Type | Allowed Pattern | Forbidden Pattern |
|---|---|---|
| Writing data | Through owning module's service API | Direct table writes from non-owner |
| Reading owned data | Through owning module's query API | Direct table reads from non-owner |
| Reading for UI composition | Views or read replicas | Joins across module schemas |
| Shared reference data | Shared kernel module | Duplicating data across modules |
| Reporting/Analytics | Dedicated read models | Production table complex queries |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
-- Each module has its own schemaCREATE SCHEMA ordering;CREATE SCHEMA inventory;CREATE SCHEMA payments;CREATE SCHEMA customers; -- Tables are prefixed with schemaCREATE TABLE ordering.orders ( id UUID PRIMARY KEY, customer_id UUID NOT NULL, -- Reference to customers.customers.id status VARCHAR(50) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW()); CREATE TABLE ordering.order_items ( id UUID PRIMARY KEY, order_id UUID REFERENCES ordering.orders(id), product_id UUID NOT NULL, -- Reference to catalog.products.id quantity INT NOT NULL, price_at_order DECIMAL(10,2) NOT NULL); CREATE TABLE inventory.stock_levels ( id UUID PRIMARY KEY, product_id UUID NOT NULL, warehouse_id UUID NOT NULL, quantity INT NOT NULL, reserved INT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ DEFAULT NOW()); -- FORBIDDEN: Cross-schema foreign keys-- This creates tight coupling that prevents extraction-- ALTER TABLE ordering.orders -- ADD CONSTRAINT fk_customer -- FOREIGN KEY (customer_id) REFERENCES customers.customers(id); -- ALLOWED: Read-only views for legitimate cross-module queriesCREATE VIEW reporting.order_summary ASSELECT o.id, o.status, o.created_at, c.email as customer_email, SUM(oi.quantity * oi.price_at_order) as totalFROM ordering.orders oJOIN customers.customers c ON o.customer_id = c.idJOIN ordering.order_items oi ON o.id = oi.order_idGROUP BY o.id, o.status, o.created_at, c.email;Foreign keys across schemas create physical coupling that prevents independent extraction. Use soft references (store the ID) and enforce consistency through application logic or eventual consistency patterns.
The modular monolith occupies a sweet spot: much of the structural benefit of microservices without the operational complexity.
| Concern | Modular Monolith | Microservices |
|---|---|---|
| Deployments per release | 1 | N (coordinated) |
| Network call failures | 0 | Common |
| Distributed tracing | Optional | Essential |
| Cross-module transactions | Native ACID | Saga patterns |
| Infrastructure footprint | Small | Large |
| Team required to operate | Standard DevOps | Platform team + DevOps |
| Debugging complexity | Standard | Distributed systems expertise |
For many organizations, a modular monolith provides 80% of the structural benefits of microservices with 20% of the operational complexity. It's a pragmatic default that can evolve when—if—more distribution is needed.
The modular monolith is not a perfect solution. Understanding its limitations helps you recognize when to evolve beyond it.
Signs It's Time to Extract Services:
Scaling Divergence — One module consistently needs 5-10x more capacity than others, and you're wasting significant compute.
Deployment Friction — The full test suite takes too long; teams are blocked waiting for releases; deployment risk is too high.
Team Independence Needs — Teams want different release cadences, technology choices, or operational responsibility. Coordination is causing friction.
Database Bottlenecks — The shared database is at capacity. Sharding is needed, but the shared schema makes it complex.
Fault Isolation Requirements — A module handles critical traffic that must be isolated from less critical features. Current blast radius is unacceptable.
When these signs appear, extract the specific module causing friction—not everything at once.
You don't need to extract all modules. Extract the one causing problems. A hybrid architecture—a modular monolith with a few extracted services—is common and pragmatic. Don't pursue architectural purity; pursue solving problems.
The modular monolith can be a destination or a waypoint. Understanding migration paths in both directions is essential.
From Traditional Monolith to Modular Monolith:
Identify Bounded Contexts — Map your domain to identify natural module boundaries. Use event storming, change frequency analysis, or team boundaries.
Draw Private Boundaries — Start by making existing packages/namespaces internal. Identify what should be public API vs. implementation detail.
Extract Shared Kernel — Create a module for genuinely shared code (utilities, common types, cross-cutting concerns). Keep it minimal.
Break Cyclic Dependencies — Where modules have circular dependencies, introduce abstractions or events to break the cycle.
Enforce Ownership — Assign tables to modules. Refactor code that accesses other modules' tables to go through the owning module's API.
Add Enforcement — Implement architecture tests, adjust build structure, and add CI checks to prevent boundary violations.
This can be done incrementally, module by module, without stopping feature development.
From Modular Monolith to Microservices (Strangler Fig):
Select the Module to Extract — Choose based on the signals discussed: scaling needs, team needs, fault isolation needs. Start with one module, not all.
Create the New Service — Build the service with its own deployment, database, and infrastructure. It should expose the same API contract as the module.
Route Traffic Progressively — Use a feature flag or routing layer to send a percentage of traffic to the new service. Monitor carefully.
Migrate Data — If the service needs historical data, migrate it. For new data, the service becomes the system of record.
Remove Module Code — Once 100% of traffic is on the new service and data is migrated, remove the module from the monolith.
Repeat as Needed — Extract additional modules only when problems justify the complexity.
The modular monolith makes this process straightforward because boundaries are already defined.
Many mature organizations end up with a hybrid: a modular monolith containing most functionality, plus a handful of extracted services for specific scaling, isolation, or technology needs. This pragmatic architecture is more common than pure microservices.
We've explored the modular monolith in depth—an architecture that combines deployment simplicity with structural discipline.
What's Next:
We've now covered the three primary architectural patterns: monolith, microservices, and modular monolith. The next page examines evolution paths—how systems naturally progress from one architecture to another, and how to manage that progression effectively.
You now understand the modular monolith as a pragmatic alternative to both unstructured monoliths and distributed microservices. You can design module boundaries, enforce them, manage cross-module communication and data, and recognize when to evolve beyond this architecture.