If you’ve been scrolling through tech Twitter lately, you’ve probably encountered the modern engineer’s equivalent of a religious war: microservices versus monoliths. One camp insists that monoliths are dinosaurs headed for extinction. The other swears by the simplicity of a single codebase. Meanwhile, neither side is talking about the architecture that’s quietly winning in production environments across the industry: the modular monolith. Here’s the thing about hype cycles: they’re excellent at obscuring practical truth. In 2026, as we’ve finally stepped back from the microservices fever dream of the 2020s, a more mature conversation is emerging. It’s not “Which architecture is better?” but rather “Which architecture is right for my team, my product, and my constraints right now?” The answer, for most organizations, is a well-structured modular monolith—at least until growth forces a different path.
The False Binary: Why You Don’t Have to Choose
Let me cut to the chase: the monolith versus microservices debate has been holding us hostage to a false dichotomy. A monolithic architecture isn’t inherently bad. It’s simply a single, self-contained application platform with interconnected modules and layers. The problem isn’t the monolith itself—it’s the spaghetti code monolith: a chaotic codebase where modules bleed into each other, dependencies sprawl in every direction, and changing one feature triggers a cascade of unintended consequences. On the flip side, microservices aren’t a magical solution either. They’re powerful when you have organizational maturity, DevOps expertise, and the operational bandwidth to manage distributed systems. But they exact a real cost: complexity in inter-service communication, challenges with data consistency, and the overhead of managing multiple deployment pipelines. Here’s what the industry has learned through collective trial and error: you need to grow into complexity, not start there. Enter the modular monolith—a third way that’s been quietly gaining traction and delivering tangible results.
What Is a Modular Monolith?
A modular monolith organizes a single codebase into loosely coupled modules, each with clear boundaries, isolated layers, and clean contracts. It’s the sweet spot between the simplicity of a traditional monolith and the structural discipline of microservices. Think of it this way: instead of having thousands of functions calling each other willy-nilly across a massive codebase, you create logical domains within the same application. Each domain owns its data, exposes a well-defined API, and communicates with other domains through structured interfaces. When the time comes to extract a service into a microservice, you’re not untangling spaghetti—you’re just extracting what’s already modular. The fundamental insight: a modular monolith buys you time, clarity, and performance without locking you into one architectural path forever.
Why Modular Monoliths Win in 2026
1. Operational Simplicity
A modular monolith requires significantly fewer DevOps engineers than an equivalent microservices architecture. According to recent data, a modular monolith might require 1-2 operations-focused engineers, while the same system split into microservices typically demands 2-4 platform engineers. For teams that don’t have a dedicated platform engineering team (which is most teams), this matters enormously.
2. Development Speed
There’s no hand-waving around this: you can build features faster in a well-structured monolith. You trace logic in one place. You avoid orchestrating distributed transactions. You don’t have to worry about API versioning between internal components or deploying a dozen services to test a single feature. For small to mid-sized teams trying to find product-market fit, this productivity advantage is transformative.
3. Cost Efficiency
Infrastructure costs are real. Deployment complexity is real. The operational overhead of managing dozens of services is real. A modular monolith reduces all of these, which means your engineering budget goes toward building features, not managing infrastructure.
4. Cognitive Load
Humans can only hold so much context in their heads. A single, well-organized codebase is genuinely easier to reason about than a system spread across twenty microservices, each with its own database, API, and deployment pipeline.
5. The Path Forward
Unlike a traditional monolith, a modular monolith has the seams already in place. When you need to scale a specific part of your system (checkout, search, authentication), you’re not re-architecting. You’re extracting what’s already modular. This is the architectural equivalent of leaving yourself a treasure map—future you will be grateful.
Designing Your Modular Monolith: Core Principles
Let’s get concrete. Here’s how to structure a modular monolith that actually survives contact with reality.
Principle 1: Domain-Driven Design (DDD) is Your Foundation
Your modules should align with business domains, not technical layers. Instead of models/, services/, controllers/, think users/, orders/, payments/.
Each domain is a loosely coupled, cohesive unit. It can evolve independently. It has clear entry points and responsibilities.
Principle 2: Establish Firm Module Boundaries
Here’s where most monoliths fail: they have fuzzy boundaries. Code from one module creeps into another. Dependencies cross over in hidden ways. Suddenly, you can’t change one thing without touching five others. Strict boundaries are non-negotiable:
- Each module has a public API (an
api.tsorpublic.pyfile that explicitly exports what the module exposes) - Internal implementation details are private
- Cross-module communication happens through well-defined interfaces, not by poking into each other’s internals
- Use dependency injection to manage module interactions
Principle 3: Each Module Owns Its Data
In a microservices architecture, each service typically owns its database. In a modular monolith, each module should own its tables or schema. This creates natural isolation. A user authentication module doesn’t read directly from the orders module’s tables. It doesn’t even know that schema exists. If it needs order data, it requests it through the orders module’s API.
Principle 4: Synchronous Internal APIs, Event-Driven When Needed
Within the same process, modules communicate synchronously through clean function calls or internal APIs. This is fine—it’s a single process, no network overhead. For operations that genuinely need asynchronous coordination (user signs up → send welcome email → create analytics record), use event publishing internal to the monolith. Most frameworks support this elegantly.
Architecture in Action: A Practical Example
Let me walk you through a concrete example: an e-commerce system. Instead of one bloated codebase, you’d structure it like this:
src/
├── users/ # User management domain
│ ├── public.ts # Public API
│ ├── models/
│ ├── repository/
│ └── service/
├── products/ # Product catalog domain
│ ├── public.ts
│ ├── models/
│ ├── repository/
│ └── service/
├── orders/ # Order management domain
│ ├── public.ts
│ ├── models/
│ ├── repository/
│ └── service/
├── payments/ # Payment processing domain
│ ├── public.ts
│ ├── models/
│ ├── repository/
│ └── service/
└── shared/ # Cross-cutting concerns
├── events/
├── errors/
└── middleware/
Each public.ts file is the contract:
// src/orders/public.ts
export { OrderService } from './service/OrderService';
export type { CreateOrderRequest, OrderDTO } from './models';
export { OrderRepository } from './repository/OrderRepository';
// Everything else is internal—other modules can't import directly from
// service details, repository implementations, or internal models
Other modules import only from public.ts:
// src/payments/service/PaymentProcessor.ts
import { OrderService } from '../orders/public';
export class PaymentProcessor {
async processPayment(orderId: string, amount: number) {
const order = await this.orderService.getOrder(orderId);
// Process payment...
}
}
Notice what’s not happening: the payments module isn’t querying the orders database directly. It’s not importing internal order models. It’s not aware of how orders are stored. It knows one thing: how to call the orders module’s public API. This is how you keep modules decoupled.
Visualizing Your Modular Monolith
Here’s what the dependency flow looks like:
(public.ts)"] UserLogic["User Logic"] UserDB["Users Table"] end subgraph "Products Domain" ProductAPI["Product Service
(public.ts)"] ProductLogic["Product Logic"] ProductDB["Products Table"] end subgraph "Orders Domain" OrderAPI["Order Service
(public.ts)"] OrderLogic["Order Logic"] OrderDB["Orders Table"] end subgraph "Shared" Events["Event Bus"] Logger["Logger"] end Client -->|HTTP Request| Router Router -->|Call| UserAPI Router -->|Call| OrderAPI Router -->|Call| ProductAPI OrderAPI -->|Use| OrderLogic OrderLogic -->|Query| OrderDB OrderLogic -->|Call| UserAPI OrderLogic -->|Call| ProductAPI OrderLogic -->|Publish| Events UserAPI -->|Use| UserLogic UserLogic -->|Query| UserDB ProductAPI -->|Use| ProductLogic ProductLogic -->|Query| ProductDB UserLogic -->|Log| Logger OrderLogic -->|Log| Logger ProductLogic -->|Log| Logger
Notice the structure: modules are independent units that communicate through public APIs. Data access is isolated within domains. Shared concerns (logging, events) are available to all. No circular dependencies. No spaghetti.
Step-by-Step Implementation Guide
Step 1: Define Your Domains
Before you write a line of code, map out your business domains. Ask yourself:
- What are the major business capabilities?
- What teams might eventually own different parts?
- Where are natural boundaries? For an e-commerce system: users, products, orders, payments, shipping, reviews. For a SaaS platform: accounts, subscriptions, usage, billing, integrations. Write these down. These become your modules.
Step 2: Create Module Scaffolding
Set up the directory structure. Each module gets its own folder with:
public.ts– the only file that external code imports frommodels/– domain-specific types and data structuresservice/– business logicrepository/– data accesstypes.ts– type definitions (if complex)index.ts– internal barrel export (if needed)
src/
├── users/
│ ├── public.ts # ← Only this is imported externally
│ ├── models/
│ │ ├── User.ts
│ │ └── UserRole.ts
│ ├── service/
│ │ └── UserService.ts
│ ├── repository/
│ │ └── UserRepository.ts
│ └── types.ts
Step 3: Define Public APIs Explicitly
Your public.ts is the contract. It’s the only thing other modules see.
// src/users/public.ts
export { UserService } from './service/UserService';
export type { User, CreateUserRequest, UserDTO } from './models/User';
// Explicitly don't export:
// - UserRepository (internal implementation)
// - Internal utility functions
// - Implementation details
Step 4: Implement with Dependency Injection
Use dependency injection to wire up dependencies. This keeps modules loose and testable.
// src/users/service/UserService.ts
export class UserService {
constructor(private repository: UserRepository) {}
async createUser(request: CreateUserRequest): Promise<UserDTO> {
const user = await this.repository.create(request);
return this.mapToDTO(user);
}
}
// src/orders/service/OrderService.ts
export class OrderService {
constructor(
private repository: OrderRepository,
private userService: UserService,
private eventBus: EventBus
) {}
async createOrder(request: CreateOrderRequest): Promise<OrderDTO> {
// Validate user exists
const user = await this.userService.getUser(request.userId);
// Create order
const order = await this.repository.create(request);
// Publish event
await this.eventBus.publish('order.created', { orderId: order.id });
return this.mapToDTO(order);
}
}
Step 5: Enforce Boundaries with Linting Rules
Most modern linters support enforcing architectural boundaries. In TypeScript/JavaScript ecosystems, use ESLint with plugins:
{
"rules": {
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "./src/orders",
"from": "./src/payments",
"except": ["./src/payments/public.ts"]
},
{
"target": "./src/users",
"from": "./src/orders",
"except": ["./src/orders/public.ts"]
}
]
}
]
}
}
This prevents accidental imports from internal module code. If someone tries to import from orders/service/, the linter catches it immediately.
Step 6: Use Events for Loose Coupling
For operations that require coordination but shouldn’t create direct dependencies, use internal events.
// src/shared/events/EventBus.ts
export interface Event {
type: string;
payload: Record<string, any>;
timestamp: Date;
}
export class EventBus {
private listeners: Map<string, Set<Function>> = new Map();
subscribe(eventType: string, handler: (event: Event) => Promise<void>) {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, new Set());
}
this.listeners.get(eventType)!.add(handler);
}
async publish(eventType: string, payload: Record<string, any>) {
const event: Event = {
type: eventType,
payload,
timestamp: new Date()
};
const handlers = this.listeners.get(eventType) || new Set();
await Promise.all(Array.from(handlers).map(h => h(event)));
}
}
// src/users/service/UserService.ts
export class UserService {
constructor(
private repository: UserRepository,
private eventBus: EventBus
) {}
async createUser(request: CreateUserRequest): Promise<UserDTO> {
const user = await this.repository.create(request);
await this.eventBus.publish('user.created', { userId: user.id });
return this.mapToDTO(user);
}
}
// Somewhere in your bootstrap code, external handlers subscribe
eventBus.subscribe('user.created', async (event) => {
// Send welcome email, create analytics record, etc.
});
Step 7: Test Module Boundaries
Write tests that verify modules respect boundaries:
// src/__tests__/architecture.test.ts
import { execSync } from 'child_process';
describe('Architecture boundaries', () => {
it('should not allow orders to import from payments internals', () => {
const result = execSync(
'grep -r "from.*payments/service" src/orders',
{ encoding: 'utf-8', stdio: 'pipe' }
).catch(() => '');
expect(result).toBe('');
});
it('should only allow imports from public.ts files', () => {
const invalidImports = execSync(
'grep -r "from.*\\/[^/]*\\/(service|repository|models)" src --include="*.ts" | grep -v public.ts',
{ encoding: 'utf-8', stdio: 'pipe' }
).catch(() => '');
expect(invalidImports).toBe('');
});
});
Real Code Example: Building an Order Module
Here’s what a complete, production-ready order module looks like:
// src/orders/public.ts
export { OrderService } from './service/OrderService';
export type { Order, CreateOrderRequest, OrderDTO, OrderStatus } from './models/Order';
export { OrderRepository } from './repository/OrderRepository';
// src/orders/models/Order.ts
export interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: OrderStatus;
createdAt: Date;
updatedAt: Date;
}
export enum OrderStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
SHIPPED = 'SHIPPED',
DELIVERED = 'DELIVERED',
CANCELLED = 'CANCELLED'
}
export interface CreateOrderRequest {
userId: string;
items: Array<{ productId: string; quantity: number }>;
}
export interface OrderDTO {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: OrderStatus;
createdAt: string;
}
export interface OrderItem {
productId: string;
quantity: number;
price: number;
}
// src/orders/service/OrderService.ts
import { UserService } from '../../users/public';
import { ProductService } from '../../products/public';
import { EventBus } from '../../shared/events/EventBus';
import { OrderRepository } from '../repository/OrderRepository';
import { Order, CreateOrderRequest, OrderStatus } from '../models/Order';
export class OrderService {
constructor(
private repository: OrderRepository,
private userService: UserService,
private productService: ProductService,
private eventBus: EventBus
) {}
async createOrder(request: CreateOrderRequest): Promise<Order> {
// Validate user exists
const user = await this.userService.getUser(request.userId);
if (!user) {
throw new Error('User not found');
}
// Validate products and calculate total
let total = 0;
const items = [];
for (const item of request.items) {
const product = await this.productService.getProduct(item.productId);
if (!product) {
throw new Error(`Product ${item.productId} not found`);
}
const itemTotal = product.price * item.quantity;
total += itemTotal;
items.push({
productId: item.productId,
quantity: item.quantity,
price: product.price
});
}
// Create order
const order: Order = {
id: this.generateId(),
userId: request.userId,
items,
total,
status: OrderStatus.PENDING,
createdAt: new Date(),
updatedAt: new Date()
};
await this.repository.save(order);
// Publish event—other modules can listen without coupling
await this.eventBus.publish('order.created', {
orderId: order.id,
userId: order.userId,
total: order.total,
itemCount: order.items.length
});
return order;
}
async getOrder(orderId: string): Promise<Order | null> {
return this.repository.findById(orderId);
}
async updateOrderStatus(orderId: string, status: OrderStatus): Promise<Order> {
const order = await this.repository.findById(orderId);
if (!order) {
throw new Error('Order not found');
}
order.status = status;
order.updatedAt = new Date();
await this.repository.save(order);
await this.eventBus.publish('order.status_changed', {
orderId: order.id,
newStatus: status
});
return order;
}
private generateId(): string {
return `ORD_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
// src/orders/repository/OrderRepository.ts
import { Order } from '../models/Order';
export class OrderRepository {
// In a real application, this would interact with a database
private orders: Map<string, Order> = new Map();
async save(order: Order): Promise<void> {
this.orders.set(order.id, { ...order });
}
async findById(id: string): Promise<Order | null> {
return this.orders.get(id) || null;
}
async findByUserId(userId: string): Promise<Order[]> {
return Array.from(this.orders.values()).filter(o => o.userId === userId);
}
}
Common Pitfalls and How to Avoid Them
Pitfall 1: Shared Databases Across Modules
If two modules directly query each other’s tables, you’ve recreated the monolith problem in a different form. Solution: Each module owns its schema. If another module needs data, it goes through the public API.
Pitfall 2: Circular Dependencies
Module A calls Module B, which calls Module A. This creates tight coupling and makes testing impossible. Solution: Use the dependency inversion principle. If you need circular communication, introduce an event bus.
Pitfall 3: Modules That Are Too Small
You can take modularity too far. If every utility function is its own module, you’ve created overhead without benefit. Solution: Modules should align with business domains, not with technical layers. A module should be meaningful enough that a team could potentially own it.
Pitfall 4: No Enforcement Mechanism
You can intend for modules to respect boundaries, but without linting rules or tests, they inevitably drift. Solution: Enforce boundaries with tools. Make violations obvious. Fail builds if modules violate the architecture.
When to Migrate to Microservices
The beauty of a modular monolith is that you build the migration path from day one. Migrate a module to a microservice when:
- A specific domain has different scaling needs. Your checkout module gets hammered during sales but your analytics module doesn’t need to scale. Extract checkout into its own service.
- Organizational structure demands it. You have a dedicated team that needs to deploy independently, move at their own velocity, and own their domain end-to-end. The service boundary aligns with the team boundary.
- Technology diversity is essential. You want to use a different language, framework, or database for a specific domain. A service boundary lets you do that without infecting the rest of the system.
- Deployment frequency diverges significantly. One team is shipping features multiple times per day while another deploys quarterly. At that point, separate services make sense.
- The operational cost of staying monolithic exceeds the cost of splitting. Because your modules are already loosely coupled, extraction is straightforward:
- Wrap the module’s
public.tsin an HTTP server - Replace internal function calls with REST/gRPC calls
- Update the event bus to use a message broker
- Deploy as a separate service You’re not untangling spaghetti. You’re just moving a well-defined piece out of the monolith.
The Operational Reality
Let’s be honest: microservices are complex. You need:
- Multiple databases to manage
- Distributed transaction handling
- API versioning
- Service discovery
- Circuit breakers
- Distributed tracing
- Multiple deployment pipelines A modular monolith gives you 80% of the modularity benefits with 20% of the operational complexity. In 2026, with rising costs and the maturation of engineering organizations, that trade-off is increasingly attractive.
Final Thoughts
The microservices hype cycle crested a few years ago, and what’s emerged is more nuanced. The industry has realized that the correct default is not “monolith” or “microservices”—it’s “modular monolith until you have a reason to split.” A well-structured modular monolith is every bit as elegant as microservices, without the operational burden. It gives you:
- Fast development velocity
- Simple deployment
- Clear debugging and monitoring
- Low operational overhead
- A clear migration path to services when needed Start here. Build discipline around module boundaries. Invest in linting rules and tests that enforce architecture. Learn your domain deeply. And when the time comes to extract a service—when growth and organizational structure demand it—you’ll have the seams already in place. That’s how you survive the hype cycle and build architecture that scales with your team and business.
