The microservices pendulum is swinging back. After years of splitting applications into dozens or hundreds of services, plenty of teams are quietly consolidating. Prime Video cut its costs by 90% by moving to a monolith. DHH has been vocal about "The Majestic Monolith." 42% of teams said in 2025 they're reconsidering their microservices strategy. The shorter version: most applications didn't need the complexity in the first place. Teams that have done the migration to a modular monolith report about 60% reduction in operational overhead, 40% lower infrastructure costs, and 3x faster development cycles.
This guide covers when consolidation makes sense, how to actually do the migration without breaking everything, and how to keep the architectural benefits of both approaches. If you're keeping some services distributed, the database-per-service vs shared database matrix is the next decision waiting for you.
What a modular monolith actually is
A modular monolith is not the tangled monolith you remember from 2012. It's a single deployable application with:
- Strong module boundaries, with clear interfaces enforced at compile time
- Independent development across teams without merge conflicts
- Shared infrastructure: one deployment pipeline, one database, one runtime
- Logical separation, where modules communicate through public APIs and never reach into each other's internals
- The option to extract modules to microservices later if some module genuinely needs to scale on its own
The difference from microservices is that modules run in the same process and the same deployment unit. No network calls between modules, no distributed transactions, no coordinated deploys.
The difference from a traditional monolith is that the boundaries are enforced. The "big ball of mud" problem comes from teams reaching across module boundaries because nothing stops them. Modular monoliths use tooling, linting, and discipline to make those shortcuts impossible.
How to tell if you should consolidate
Signs microservices are hurting you
Chatty services dominate your traffic.
// Anti-pattern: 10+ network calls for single user request
async function getOrderDetails(orderId) {
const order = await orderService.getOrder(orderId);
const user = await userService.getUser(order.userId);
const product = await productService.getProduct(order.productId);
const inventory = await inventoryService.check(order.productId);
const pricing = await pricingService.calculate(order.productId, user.tier);
const shipping = await shippingService.estimate(user.address, product.weight);
const payment = await paymentService.getMethod(user.id);
const loyalty = await loyaltyService.getPoints(user.id);
const recommendations = await recommendationService.get(user.id);
const reviews = await reviewService.getByProduct(order.productId);
// 10+ network hops = 500-2000ms latency
// 10+ potential failure points
// Distributed tracing nightmare
}
In modular monolith:
// Single process, direct function calls
async function getOrderDetails(orderId: string): Promise<OrderDetails> {
// All modules in same process - no network overhead
const order = orderModule.getOrder(orderId);
const user = userModule.getUser(order.userId);
const product = productModule.getProduct(order.productId);
const inventory = inventoryModule.check(order.productId);
// ... all in-process calls
// Total time: 10-50ms
// Single transaction possible
// Direct debugging with breakpoints
}
Distributed transactions are everywhere. If you've ended up writing sagas, two-phase commits, or eventual-consistency workarounds for every business operation, you've distributed a problem that should be local.
Your team spends more time on infrastructure than on features. DevOps overhead at 40% or more of engineering time. Managing 20+ services. Coordinating deploys. Hunting cross-service bugs. Maintaining a service mesh. Wading through distributed traces just to figure out what failed. Even a modular monolith still benefits from zero-downtime deployment patterns, but without the cross-service coordination tax on top.
Development velocity has cratered. A simple change requires updating 5 services, coordinating 5 deployments, running cross-service integration tests, debugging in production with distributed tracing, and waiting on other teams to ship their pieces.
Your microservices don't actually scale independently. If everything scales together because the services are tightly coupled, you're paying the microservices tax with none of the benefits.
When to stay distributed
There are real reasons to keep services split:
- Services have genuinely different load patterns (payment processing vs analytics)
- You need different runtimes (Python for ML, Go for high-perf, Node for APIs)
- 100+ engineers spread across teams and locations with clear service ownership
- Different services have different compliance scopes (PCI-DSS, HIPAA)
Modular monolith patterns
Pattern 1: vertical slice architecture
Organize by feature/capability rather than technical layers:
// Project structure - each module is self-contained
src/
├── modules/
│ ├── orders/
│ │ ├── domain/
│ │ │ ├── Order.ts
│ │ │ ├── OrderStatus.ts
│ │ │ └── OrderRepository.ts
│ │ ├── application/
│ │ │ ├── CreateOrderCommand.ts
│ │ │ ├── OrderService.ts
│ │ │ └── OrderQueries.ts
│ │ ├── infrastructure/
│ │ │ ├── OrderRepositoryImpl.ts
│ │ │ └── OrderEventPublisher.ts
│ │ ├── api/
│ │ │ └── OrderController.ts
│ │ └── index.ts // Public module API
│ │
│ ├── users/
│ │ ├── domain/
│ │ ├── application/
│ │ ├── infrastructure/
│ │ ├── api/
│ │ └── index.ts
│ │
│ ├── products/
│ │ └── ... (same structure)
│ │
│ └── shared/
│ ├── events/
│ ├── errors/
│ └── utils/
│
├── infrastructure/
│ ├── database/
│ ├── cache/
│ └── messaging/
└── main.ts
// Module interface - ONLY this is exposed to other modules
// modules/orders/index.ts
export interface OrdersModule {
createOrder(command: CreateOrderCommand): Promise<Order>;
getOrder(id: string): Promise<Order | null>;
updateOrderStatus(id: string, status: OrderStatus): Promise<void>;
// Internal details hidden - no direct access to repository
}
export const ordersModule: OrdersModule = {
createOrder: OrderService.create,
getOrder: OrderService.getById,
updateOrderStatus: OrderService.updateStatus
};
// Other modules can ONLY use the public interface
// modules/shipping/application/ShippingService.ts
import { ordersModule } from '@/modules/orders';
export class ShippingService {
async shipOrder(orderId: string) {
// Use only public interface
const order = await ordersModule.getOrder(orderId);
// ❌ CANNOT DO THIS - repository is internal to orders module
// const order = await OrderRepository.findById(orderId);
if (order && order.status === OrderStatus.Paid) {
// Process shipping
}
}
}
Pattern 2: DDD modules
Structure modules as bounded contexts:
// Bounded context: Order Management
// modules/order-management/domain/aggregates/Order.ts
export class Order {
private constructor(
public readonly id: OrderId,
public readonly customerId: CustomerId,
private items: OrderItem[],
private status: OrderStatus,
private totalAmount: Money
) {}
// Business logic encapsulated in aggregate
static create(customerId: CustomerId, items: OrderItem[]): Order {
if (items.length === 0) {
throw new DomainException('Order must have at least one item');
}
const totalAmount = items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity)),
Money.zero()
);
return new Order(
OrderId.generate(),
customerId,
items,
OrderStatus.Pending,
totalAmount
);
}
placeOrder(): void {
if (this.status !== OrderStatus.Pending) {
throw new DomainException('Order can only be placed when pending');
}
this.status = OrderStatus.Placed;
this.recordEvent(new OrderPlacedEvent(this.id, this.customerId));
}
cancel(): void {
if (![OrderStatus.Pending, OrderStatus.Placed].includes(this.status)) {
throw new DomainException('Cannot cancel order in current status');
}
this.status = OrderStatus.Cancelled;
this.recordEvent(new OrderCancelledEvent(this.id));
}
private recordEvent(event: DomainEvent): void {
// Event sourcing or domain event pattern
}
}
// Module boundary - Application Service
// modules/order-management/application/OrderApplicationService.ts
export class OrderApplicationService {
constructor(
private orderRepository: OrderRepository,
private customerRepository: CustomerRepository,
private inventoryService: InventoryService,
private eventBus: EventBus
) {}
async createOrder(command: CreateOrderCommand): Promise<OrderId> {
// Validate customer exists
const customer = await this.customerRepository.findById(command.customerId);
if (!customer) {
throw new ApplicationException('Customer not found');
}
// Check inventory availability
for (const item of command.items) {
const available = await this.inventoryService.checkAvailability(
item.productId,
item.quantity
);
if (!available) {
throw new ApplicationException(`Product ${item.productId} not available`);
}
}
// Create order aggregate
const order = Order.create(command.customerId, command.items);
order.placeOrder();
// Persist
await this.orderRepository.save(order);
// Publish events
const events = order.getUncommittedEvents();
await this.eventBus.publishAll(events);
return order.id;
}
}
Pattern 3: event-driven modular monolith
Modules communicate through domain events:
// Shared event bus within the monolith
// infrastructure/events/EventBus.ts
export class InProcessEventBus implements EventBus {
private handlers: Map<string, Array<(event: DomainEvent) => Promise<void>>> = new Map();
subscribe<T extends DomainEvent>(
eventType: string,
handler: (event: T) => Promise<void>
): void {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, []);
}
this.handlers.get(eventType)!.push(handler as any);
}
async publish(event: DomainEvent): Promise<void> {
const handlers = this.handlers.get(event.type) || [];
// Execute all handlers in parallel (within same process)
await Promise.all(handlers.map(handler => handler(event)));
}
async publishAll(events: DomainEvent[]): Promise<void> {
for (const event of events) {
await this.publish(event);
}
}
}
// Order module publishes events
// modules/order-management/domain/events/OrderPlacedEvent.ts
export class OrderPlacedEvent implements DomainEvent {
readonly type = 'OrderPlaced';
readonly occurredAt = new Date();
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly totalAmount: number,
public readonly items: Array<{ productId: string; quantity: number }>
) {}
}
// Inventory module subscribes to events
// modules/inventory/application/OrderEventHandlers.ts
export class OrderEventHandlers {
constructor(
private inventoryService: InventoryService,
private eventBus: EventBus
) {
this.setupHandlers();
}
private setupHandlers(): void {
// Handle order placed - reserve inventory
this.eventBus.subscribe<OrderPlacedEvent>(
'OrderPlaced',
async (event) => {
for (const item of event.items) {
await this.inventoryService.reserve(
item.productId,
item.quantity,
event.orderId
);
}
}
);
// Handle order cancelled - release inventory
this.eventBus.subscribe<OrderCancelledEvent>(
'OrderCancelled',
async (event) => {
await this.inventoryService.releaseReservation(event.orderId);
}
);
}
}
// Benefits:
// - Loose coupling between modules
// - No network overhead (in-process events)
// - Easy to test (inject fake event bus)
// - Can later extract to message broker if needed
// - Single transaction across modules possible
Migration strategy
Phase 1: assess and plan
// Create a service dependency map
interface ServiceDependency {
service: string;
dependsOn: string[];
callsPerMinute: number;
avgLatency: number;
sharedData: boolean;
}
const serviceMap: ServiceDependency[] = [
{
service: 'order-service',
dependsOn: ['user-service', 'product-service', 'inventory-service', 'pricing-service'],
callsPerMinute: 5000,
avgLatency: 150,
sharedData: true
},
{
service: 'user-service',
dependsOn: ['auth-service', 'notification-service'],
callsPerMinute: 10000,
avgLatency: 50,
sharedData: false
},
// ... map all services
];
// Identify consolidation candidates
function identifyConsolidationGroups(services: ServiceDependency[]): string[][] {
const groups: string[][] = [];
// Group 1: Chatty services (high inter-service calls)
const chattyServices = services.filter(s => s.callsPerMinute > 1000 && s.dependsOn.length > 3);
// Group 2: Services with shared data
const sharedDataServices = services.filter(s => s.sharedData);
// Group 3: Services with high latency due to network hops
const highLatencyServices = services.filter(s => s.avgLatency > 100);
return [chattyServices.map(s => s.service), sharedDataServices.map(s => s.service)];
}
// Prioritization matrix
interface ConsolidationCandidate {
services: string[];
complexity: 'low' | 'medium' | 'high';
benefit: 'low' | 'medium' | 'high';
estimatedSavings: number;
}
const candidates: ConsolidationCandidate[] = [
{
services: ['order-service', 'inventory-service', 'pricing-service'],
complexity: 'medium',
benefit: 'high',
estimatedSavings: 50000 // annual infrastructure savings
},
// ... prioritize consolidations
];
Phase 2: build the monolith shell
// Set up the monolith structure with enforced boundaries
// Architecture decision: Use NestJS for dependency injection and modularity
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global middleware
app.useGlobalPipes(new ValidationPipe());
app.useGlobalFilters(new GlobalExceptionFilter());
await app.listen(3000);
console.log('Modular monolith running on port 3000');
}
bootstrap();
// app.module.ts - compose all modules
import { Module } from '@nestjs/common';
import { OrderModule } from './modules/orders/order.module';
import { UserModule } from './modules/users/user.module';
import { ProductModule } from './modules/products/product.module';
import { InventoryModule } from './modules/inventory/inventory.module';
import { SharedModule } from './modules/shared/shared.module';
@Module({
imports: [
SharedModule.forRoot(), // Shared infrastructure
OrderModule,
UserModule,
ProductModule,
InventoryModule
],
})
export class AppModule {}
// modules/orders/order.module.ts
import { Module } from '@nestjs/common';
import { OrderController } from './api/order.controller';
import { OrderService } from './application/order.service';
import { OrderRepository } from './infrastructure/order.repository';
@Module({
controllers: [OrderController],
providers: [
OrderService,
OrderRepository,
],
exports: [OrderService], // Only export what other modules need
})
export class OrderModule {}
// Enforce module boundaries with ESLint
// .eslintrc.js
module.exports = {
extends: ['@nx/eslint-plugin-nx/recommended'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
allow: [],
depConstraints: [
{
sourceTag: 'scope:orders',
onlyDependOnLibsWithTags: ['scope:orders', 'scope:shared']
},
{
sourceTag: 'scope:users',
onlyDependOnLibsWithTags: ['scope:users', 'scope:shared']
},
// Prevent circular dependencies
// Prevent reaching into module internals
],
},
],
},
};
Phase 3: migrate services incrementally
// Strategy: Strangler Fig Pattern
// Step 1: Add new modular monolith alongside microservices
// Step 2: Route some traffic to monolith
// Step 3: Migrate data
// Step 4: Decommission microservices
// API Gateway routing during migration
// infrastructure/gateway/router.ts
export class MigrationRouter {
constructor(
private monolithClient: HttpClient,
private microservicesClient: HttpClient,
private featureFlags: FeatureFlagService
) {}
async routeRequest(request: Request): Promise<Response> {
const route = request.path;
const userId = request.user?.id;
// Check if this route is migrated to monolith
const isMigrated = await this.featureFlags.isEnabled(
`monolith-${route}`,
{ userId }
);
if (isMigrated) {
// Route to modular monolith
return this.monolithClient.send(request);
} else {
// Route to old microservices
return this.microservicesClient.send(request);
}
}
}
// Data migration strategy
export class DataMigrationService {
async migrateOrderData(): Promise<void> {
console.log('Starting order data migration...');
// Phase 1: Dual write (write to both systems)
await this.enableDualWrite('orders');
// Phase 2: Backfill historical data
await this.backfillData('orders', {
batchSize: 1000,
source: 'order-service-db',
target: 'monolith-db',
});
// Phase 3: Verify data consistency
const consistent = await this.verifyConsistency('orders');
if (!consistent) {
throw new Error('Data inconsistency detected');
}
// Phase 4: Switch reads to monolith
await this.switchReads('orders', 'monolith');
// Phase 5: Disable dual write, monolith is source of truth
await this.disableDualWrite('orders');
console.log('Order data migration complete');
}
private async backfillData(
entity: string,
config: { batchSize: number; source: string; target: string }
): Promise<void> {
let offset = 0;
let hasMore = true;
while (hasMore) {
const batch = await this.fetchBatch(config.source, entity, offset, config.batchSize);
if (batch.length === 0) {
hasMore = false;
break;
}
await this.writeBatch(config.target, entity, batch);
offset += config.batchSize;
console.log(`Migrated ${offset} ${entity} records`);
await this.sleep(100); // Rate limiting
}
}
}
Phase 4: consolidate the database
// Decision: Shared database with schema per module
// Use database schemas to maintain logical separation
// infrastructure/database/schema-manager.ts
export class SchemaManager {
async setupSchemas(): Promise<void> {
await this.db.raw('CREATE SCHEMA IF NOT EXISTS orders');
await this.db.raw('CREATE SCHEMA IF NOT EXISTS users');
await this.db.raw('CREATE SCHEMA IF NOT EXISTS products');
await this.db.raw('CREATE SCHEMA IF NOT EXISTS inventory');
// Set search path for each module's repository
// orders repository uses: SET search_path TO orders, public
// users repository uses: SET search_path TO users, public
}
}
// Module-specific repositories with schema isolation
// modules/orders/infrastructure/order.repository.ts
export class OrderRepository {
constructor(private db: Knex) {
// Set schema for all queries from this repository
this.db = db.withSchema('orders');
}
async save(order: Order): Promise<void> {
await this.db('orders').insert({
id: order.id,
customer_id: order.customerId,
status: order.status,
total_amount: order.totalAmount,
created_at: new Date()
});
}
async findById(id: string): Promise<Order | null> {
const row = await this.db('orders')
.where({ id })
.first();
return row ? this.toDomain(row) : null;
}
// Cross-module queries use explicit schema references
async getOrderWithCustomer(orderId: string): Promise<OrderWithCustomer> {
const result = await this.db.raw(`
SELECT
o.*,
c.name as customer_name,
c.email as customer_email
FROM orders.orders o
JOIN users.customers c ON o.customer_id = c.id
WHERE o.id = ?
`, [orderId]);
return result.rows[0];
}
}
Keeping it modular
Architectural tests
// tests/architecture/module-boundaries.test.ts
import { ModuleGraph } from '@nx/devkit';
import { createProjectGraph } from '@nx/workspace/src/core/project-graph';
describe('Module Boundaries', () => {
let graph: ModuleGraph;
beforeAll(async () => {
graph = await createProjectGraph();
});
it('orders module should not depend on inventory module directly', () => {
const orders = graph.nodes['orders'];
const dependencies = orders.data.implicitDependencies || [];
expect(dependencies).not.toContain('inventory');
});
it('modules should only communicate through public APIs', () => {
const violations = findBoundaryViolations(graph);
expect(violations).toEqual([]);
});
it('no circular dependencies between modules', () => {
const cycles = detectCycles(graph);
expect(cycles).toEqual([]);
});
it('modules should not access other modules internals', () => {
const internalAccesses = findInternalAccesses();
// Example violation: orders module importing from users/infrastructure
expect(internalAccesses).toEqual([]);
});
});
function findBoundaryViolations(graph: ModuleGraph): string[] {
const violations: string[] = [];
for (const [nodeName, node] of Object.entries(graph.nodes)) {
const filePath = node.data.files[0]?.file;
// Check imports
const imports = extractImports(filePath);
for (const importPath of imports) {
// Violation: importing from another module's internal directory
if (importPath.includes('/infrastructure/') &&
!importPath.startsWith(`modules/${nodeName}`)) {
violations.push(`${nodeName} imports from ${importPath}`);
}
}
}
return violations;
}
Module API documentation
// Generate API docs for each module
// modules/orders/README.md
# Orders Module API
## Public Interface
### Commands
- `createOrder(command: CreateOrderCommand): Promise<OrderId>`
- `updateOrderStatus(orderId: string, status: OrderStatus): Promise<void>`
- `cancelOrder(orderId: string): Promise<void>`
### Queries
- `getOrder(orderId: string): Promise<Order | null>`
- `getOrdersByCustomer(customerId: string): Promise<Order[]>`
- `getOrderHistory(orderId: string): Promise<OrderEvent[]>`
### Events Published
- `OrderCreated`
- `OrderPlaced`
- `OrderCancelled`
- `OrderShipped`
- `OrderDelivered`
### Events Consumed
- `PaymentProcessed` (from payment module)
- `InventoryReserved` (from inventory module)
## Dependencies
- Users module (for customer validation)
- Products module (for product information)
- Inventory module (for availability checks)
## Internal Implementation
**DO NOT ACCESS DIRECTLY**
- OrderRepository
- OrderEventStore
- OrderDatabase schema
Use only the public interface exported from `modules/orders/index.ts`
Performance numbers
// Benchmark: Microservices vs Modular Monolith
interface PerformanceMetrics {
scenario: string;
microservices: number; // milliseconds
monolith: number;
improvement: string;
}
const benchmarks: PerformanceMetrics[] = [
{
scenario: 'Create order (order + inventory + pricing)',
microservices: 250,
monolith: 25,
improvement: '10x faster (90% reduction)'
},
{
scenario: 'Get order details (5 service calls)',
microservices: 180,
monolith: 15,
improvement: '12x faster (92% reduction)'
},
{
scenario: 'Complex transaction (10+ services)',
microservices: 850,
monolith: 45,
improvement: '19x faster (95% reduction)'
}
];
// Infrastructure cost comparison
interface CostComparison {
item: string;
microservices: number; // monthly cost
monolith: number;
savings: number;
}
const costs: CostComparison[] = [
{
item: 'Compute (EC2/ECS)',
microservices: 3500,
monolith: 1200,
savings: 2300
},
{
item: 'Database (RDS instances)',
microservices: 2400,
monolith: 800,
savings: 1600
},
{
item: 'Load balancers',
microservices: 600,
monolith: 150,
savings: 450
},
{
item: 'Service mesh',
microservices: 400,
monolith: 0,
savings: 400
},
{
item: 'Monitoring tools',
microservices: 500,
monolith: 200,
savings: 300
}
];
// Total monthly savings: $5,050 (66% reduction)
// Annual savings: $60,600
Teams that have done this
Prime Video, 90% cost reduction
Amazon's Prime Video team consolidated their architecture. The before was multiple Lambda functions tied together with Step Functions, S3, and DynamoDB. The after was a single ECS Fargate container with a modular internal design. Results: 90% infrastructure cost reduction, 10x better scalability for the actual workload, and much simpler debugging and monitoring.
Shopify, a modular Rails monolith
Shopify runs one of the largest Rails monoliths in the world: 3+ million lines of code, 2,500+ developers, 50,000+ requests per second. Strong module boundaries are enforced through their Packwerk tooling. The result is faster development velocity than the microservices alternative they evaluated.
Wrap-up
The microservices-to-monolith migration isn't a step backward. It's a recognition that most applications operate at a scale where the microservices tax (latency, operational complexity, distributed transactions, infrastructure cost) outweighs the benefits. Modular monoliths give you the simplicity and performance of a single deployable with the clear boundaries and independent development that made microservices appealing in the first place. The teams that have done this report 60% lower operational overhead, 40% lower infrastructure costs, and 3x faster development.
What makes or breaks the migration is whether you maintain real module boundaries. Code organization, architectural tests, and team discipline are not optional. Get those right and you keep the option to extract a service later if you actually need to.
Start with your chattiest, most tightly coupled services. Consolidate those first because the performance and complexity wins are easy to see. Once the pattern is working, expand. Your future self and your engineering budget will both notice.
Next steps
- Audit your current architecture against the signs above and identify consolidation candidates
- Map service dependencies to understand actual coupling and communication patterns
- Calculate what you're currently spending on infrastructure, DevOps time, and lost velocity
- Design the module structure using DDD bounded contexts or vertical slices
- Set up architectural tests from day one to enforce module boundaries
- Start with a pilot consolidation of 2-3 tightly coupled services
- Measure and share the wins so the broader org can get behind the next round