Modular Monolith: The Architecture That Doesn't Hate You
Youâve been told there are only two ways to build software: create a chaotic âBig Ball of Mudâ monolith where everything depends on everything else, or embrace microservices and spend your days debugging network timeouts, service discovery failures, and distributed transactions that would make a database administrator weep.
This is a false choice designed to sell you consulting services and enterprise platforms.
Thereâs a third option that gives you the organizational benefits of microservices without the operational complexity that makes you wake up at 3 AM to restart a service mesh: the Modular Monolith. Itâs a single deployable application with enforced internal boundaries. You get clean separation of concerns without YAML files that read like an ancient curse.
What Makes a Monolith âModularâ
A modular monolith isnât just a well-organized folder structure that you promise to maintain. Itâs an architecture with strict, enforceable boundaries between business domains. Each module is a mini-application within your application, with its own data, its own logic, and a carefully controlled public interface.
Think of it like apartment buildings versus suburban sprawl. A traditional monolith is suburban sprawlâeverything connected, shared driveways, no clear property lines, and one neighborâs mess becomes everyoneâs problem. A modular monolith is a well-designed apartment buildingâseparate units with clear boundaries, shared infrastructure, but you canât just walk into your neighborâs kitchen.
The Module Anatomy
Hereâs what a real module looks like in practice:
src/modules/payments/
âââ domain/ # Business logic, entities, rules
â âââ paymentService.ts
â âââ paymentModels.ts
âââ infrastructure/ # Database, external APIs, implementation details
â âââ paymentRepository.ts
â âââ stripeClient.ts
âââ application/ # Use cases, orchestration
â âââ processPayment.ts
âââ index.ts # Public API - the ONLY file other modules can import
The index.ts file is your moduleâs bouncer. It controls exactly what gets exposed to the outside world:
// src/modules/payments/index.ts
export interface PaymentService {
processPayment(details: PaymentDetails): Promise<PaymentResult>;
refundPayment(paymentId: string): Promise<void>;
}
export type PaymentDetails = {
amount: number;
currency: string;
customerId: string;
};
export type PaymentResult = {
success: boolean;
transactionId?: string;
error?: string;
};
// Export the concrete implementation
export { PaymentServiceImpl as PaymentService } from './application/processPayment';
Everything else stays private. Other modules cannot import from domain/ or infrastructure/ directly. They only see what you choose to expose. This isnât just a conventionâitâs enforced by tooling.
Enforcing Boundaries (Because Good Intentions Arenât Architecture)
Hope is not a strategy. You need tooling to enforce boundaries, or your modular monolith will slowly decay into a regular monolith as deadlines approach and developers take shortcuts.
TypeScript Path Mapping:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@payments": ["modules/payments/index.ts"],
"@orders": ["modules/orders/index.ts"],
"@inventory": ["modules/inventory/index.ts"]
}
}
}
ESLint Rules That Bite:
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['*/modules/*/domain/*', '*/modules/*/infrastructure/*'],
message: 'Direct imports from module internals are forbidden. Use the module\'s public API instead.'
}
]
}
]
}
};
Now your build fails if someone tries to import @payments/domain/paymentService directly. Theyâre forced to use the public API, and your boundaries stay intact even when the team is under pressure.
Inter-Module Communication: The Right Way
Synchronous: Direct Function Calls
For immediate responses (commands and queries), use direct function calls through dependency injection. Keep it simple:
// orders module needs to process payments
export class CreateOrderUseCase {
constructor(
private paymentService: PaymentService,
private orderRepository: OrderRepository
) {}
async execute(orderData: CreateOrderRequest): Promise<Order> {
// Create order first
const order = await this.orderRepository.save(new Order(orderData));
// Process payment synchronously
const paymentResult = await this.paymentService.processPayment({
amount: order.total,
currency: 'USD',
customerId: order.customerId
});
if (!paymentResult.success) {
await this.orderRepository.delete(order.id);
throw new PaymentFailedError(paymentResult.error);
}
return order;
}
}
No network calls, no service discovery, no circuit breakers. Just function calls that either work or throw exceptions. Your IDE can trace through the entire flow, and your debugger works normally.
Asynchronous: Event-Driven Decoupling
For workflows that donât need immediate responses, use events. This is where modular monoliths really shineâyou get the decoupling benefits of event-driven architecture without the distributed systems complexity:
// orders module publishes events
export class OrderService {
constructor(private eventBus: EventBus) {}
async createOrder(orderData: CreateOrderRequest): Promise<Order> {
const order = new Order(orderData);
await this.orderRepository.save(order);
// Fire and forget - other modules can react independently
await this.eventBus.publish(new OrderCreated({
orderId: order.id,
customerId: order.customerId,
items: order.items,
total: order.total
}));
return order;
}
}
// inventory module listens for events
export class InventoryEventHandlers {
constructor(private inventoryService: InventoryService) {}
@EventHandler(OrderCreated)
async handleOrderCreated(event: OrderCreated) {
await this.inventoryService.reserveItems(event.items);
}
}
// notifications module also listens
export class NotificationEventHandlers {
constructor(private emailService: EmailService) {}
@EventHandler(OrderCreated)
async handleOrderCreated(event: OrderCreated) {
await this.emailService.sendOrderConfirmation(event.customerId, event.orderId);
}
}
The orders module doesnât know or care whoâs listening. You can add new event handlers without touching existing code. But unlike microservices, all this happens in the same process with immediate consistency and no network failures to handle.
Data Isolation: The Non-Negotiable Rule
The biggest mistake developers make is sharing database tables between modules. This creates invisible coupling that will bite you later. Donât do this. Ever.
Schema-Per-Module
Give each module its own database schema, even within the same database:
-- Database setup
CREATE SCHEMA payments;
CREATE SCHEMA orders;
CREATE SCHEMA inventory;
-- Module-specific tables
CREATE TABLE payments.transactions (
id UUID PRIMARY KEY,
amount DECIMAL NOT NULL,
currency VARCHAR(3) NOT NULL,
-- payment-specific columns only
);
CREATE TABLE orders.orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
-- order-specific columns only
);
CREATE TABLE inventory.products (
id UUID PRIMARY KEY,
sku VARCHAR(50) NOT NULL,
-- inventory-specific columns only
);
Cross-Module Data Access: Through APIs Only
When the orders module needs payment information, it goes through the payments moduleâs API, never direct to the database:
// WRONG: Direct database access breaks boundaries
const payment = await db.query('SELECT * FROM payments.transactions WHERE order_id = ?', orderId);
// RIGHT: Through the module's API
const payment = await this.paymentService.getPaymentByOrderId(orderId);
This keeps coupling low and makes it easy to extract modules into separate services later if needed.
Migration Strategy: From Ball of Mud to Modules
You donât rewrite your entire codebase overnight. Nobody has time for that, and your users donât care about your architecture dreams. Hereâs a practical migration path:
Step 1: Identify Bounded Contexts
Look for natural seams in your existing code. Common modules include:
- User management and authentication
- Payment processing
- Inventory/catalog management
- Order management
- Notifications/communications
- Reporting and analytics
Step 2: Extract One Module at a Time
Start with the module that has the fewest dependencies and the clearest boundaries:
- Create the module structure with proper folders
- Move related code into the module directories
- Define the public API in
index.ts - Update all imports to use the public API
- Add boundary enforcement rules to your linter
- Test everything before moving to the next module
Step 3: Rinse and Repeat
Extract one module per sprint. Donât try to do everything at onceâthatâs how you end up with broken builds and angry stakeholders.
When Modular Monoliths Make Sense (And When They Donât)
Modular Monoliths Are Perfect For:
- Teams of 2-20 developers who need coordination without chaos
- Applications with clear business domains that map to modules naturally
- Complex business logic that benefits from ACID transactions
- Fast iteration cycles where deployment simplicity matters
- Limited DevOps resources (one deployment is easier than fifty)
Donât Use Them When:
- You have truly independent services with different scaling needs
- Teams are geographically distributed and need complete autonomy
- Regulatory requirements demand physical separation
- Different modules need different technology stacks
- You have unlimited DevOps resources and love complexity
The Microservices Exit Strategy
The beauty of modular monoliths is optionality. When a module grows large enough or has genuinely different scaling requirements, extracting it into a separate service is straightforward:
- The module already has a well-defined API (your public interface becomes your REST API)
- Data is already isolated (just move the schema to a separate database)
- Communication patterns are established (function calls become HTTP calls, events stay events)
- Business logic is encapsulated (just add a web server around your existing code)
Youâre not locked into the monolith forever. Youâre buying time to learn your domain and make informed decisions about what actually needs to be separated.
The Bottom Line
Modular monoliths arenât a compromiseâtheyâre a strategic choice. You get the organizational benefits of microservices (clear boundaries, independent development, testable components) without the operational complexity (service discovery, network partitions, distributed transactions, deployment orchestration).
Itâs architecture for professionals who understand that complexity has a cost and that operational complexity is often more expensive than code complexity. You optimize for the problems you have today while keeping options open for tomorrowâs problems.
Most importantly, itâs an architecture that doesnât make you hate your job. You can deploy with confidence, debug with normal tools, and sleep through the night without wondering if your service mesh is having an existential crisis.
The best architecture is the one that disappears into the background and lets you focus on building features that users actually care about. Sometimes thatâs microservices. Often, itâs a well-designed modular monolith that just works.