Modular Monolith — A Pragmatic Architecture
A practical guide to Modular Monoliths: combine the simplicity of monoliths with the modularity of microservices through clear bounded contexts and strict module boundaries.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
A Modular Monolith is a software architecture that keeps the deployment simplicity of a monolith while enforcing the modular boundaries of microservices. Instead of deploying many small services, you build a single deployable unit composed of well-defined, loosely-coupled modules. Each module owns its domain, data, and public interface. Communication between modules happens through explicit APIs, not through shared database tables or direct method calls.
When to Use
- Your team is not ready for the operational complexity of microservices
- You need fast deployments and simple debugging but want clear boundaries
- You are migrating from a big ball of mud and need a stepping stone
- Your domain has natural boundaries (bounded contexts) but does not need independent scaling
- You want to defer the decision to split into microservices until you have more information
When NOT to Use
- Different modules need to scale independently (CPU, memory, or team-wise)
- Teams must deploy on different schedules without coordination
- Technology diversity per module is a hard requirement
- The organization already has mature microservices infrastructure
Module Structure
├── src/
│ ├── modules/
│ │ ├── catalog/
│ │ │ ├── domain/
│ │ │ ├── application/
│ │ │ ├── infrastructure/
│ │ │ └── api/
│ │ ├── inventory/
│ │ │ ├── domain/
│ │ │ ├── application/
│ │ │ ├── infrastructure/
│ │ │ └── api/
│ │ └── orders/
│ │ ├── domain/
│ │ ├── application/
│ │ ├── infrastructure/
│ │ └── api/
│ └── shared/
│ └── kernel/
Enforcing Boundaries
Compile-Time Boundaries
Use your build system to prevent cross-module imports:
// catalog/build.gradle
dependencies {
implementation project(':shared:kernel')
// NO dependencies on inventory or orders
}
// orders/build.gradle
dependencies {
implementation project(':shared:kernel')
implementation project(':catalog') // Only if absolutely necessary
implementation project(':inventory')
}
Database Boundaries
Each module owns its schema. No foreign keys across modules.
-- catalog schema
CREATE TABLE catalog.products (
id UUID PRIMARY KEY,
sku VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
price_cents INTEGER NOT NULL
);
-- orders schema
CREATE TABLE orders.order_items (
id UUID PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders.orders(id),
product_id UUID NOT NULL, -- No FK to catalog.products
product_name VARCHAR(255) NOT NULL, -- Denormalized at order time
quantity INTEGER NOT NULL,
unit_price_cents INTEGER NOT NULL
);
API Communication
Modules communicate through explicit APIs, not direct database access.
// catalog module exposes this interface
interface CatalogApi {
getProduct(productId: ProductId): Promise<ProductSnapshot>;
checkAvailability(productId: ProductId, quantity: number): Promise<boolean>;
}
// orders module depends on the interface, not the implementation
class PlaceOrderService {
constructor(
private catalog: CatalogApi,
private inventory: InventoryApi,
private orderRepository: OrderRepository
) {}
async execute(command: PlaceOrderCommand): Promise<void> {
const product = await this.catalog.getProduct(command.productId);
const available = await this.inventory.checkAvailability(command.productId, command.quantity);
if (!available) throw new OutOfStockError(product.id);
const order = Order.create({ ...command, productName: product.name, unitPrice: product.price });
await this.orderRepository.save(order);
}
}
Shared Kernel
A minimal shared module for cross-cutting concepts that would be overkill to duplicate:
- Base entity types with IDs and timestamps
- Domain event base classes
- Common value objects (Money, Email, Address if truly generic)
- Infrastructure helpers (date providers, ID generators)
Keep the shared kernel small. Resist the temptation to move business logic there.
Testing Strategy
| Test Scope | What It Tests | Isolation |
|---|---|---|
| In-module unit | Domain logic | No module dependencies |
| In-module integration | Adapters + DB | Real test DB per module |
| Cross-module integration | API contracts | In-memory fakes of other modules |
| Full system | End-to-end flow | Full application |
Migration to Microservices
A modular monolith is the ideal starting point for a later extraction:
- Identify the module with the clearest boundary and highest scaling need
- Extract its database into a separate schema or service
- Replace in-process API calls with HTTP/gRPC, keeping the interface stable
- Deploy as a separate service while keeping the monolith running
- Repeat for other modules
Because modules already communicate through APIs and own their data, extraction is mechanical rather than architectural.
Common Mistakes
- Shared database tables — defeats the entire purpose; use schema-per-module
- Bypassing the API — calling another module’s domain classes directly
- Bloated shared kernel — moving business logic to shared modules creates coupling
- Premature extraction — splitting to microservices before boundaries are proven
FAQ
Is a Modular Monolith just a well-structured monolith? Yes, but the discipline matters. Without explicit boundaries enforced by the build system, it becomes a big ball of mud.
How is this different from a Service-Oriented Architecture? SOA typically implies separate deployment units. A modular monolith deploys as one unit.
Can I use different tech stacks per module? No. A modular monolith uses one tech stack. If you need polyglot persistence, you are in microservices territory.
Related Resources
Microservices Architecture — When to Use and When Not To
A practical guide to microservices: benefits, trade-offs, common patterns, and when to choose them over monoliths. Covers decomposition strategies and operational complexity.
GuideMonolith to Microservices — Migration Strategies
A practical guide to decomposing monoliths: strangler fig, branch by abstraction, and incremental extraction patterns that reduce risk and preserve business continuity.
GuideHexagonal Architecture — Ports, Adapters, and Testability
A complete guide to Hexagonal Architecture (Ports and Adapters): structure applications so domain logic is isolated from frameworks, databases, and external services.
GuideClean Architecture — The Dependency Rule and Layered Boundaries
A practical guide to Uncle Bob's Clean Architecture: organize code into layers so that frameworks, UI, and databases are details, not dependencies.