The Great Architecture Debate
Choosing between a monolith and microservices is one of the most consequential architectural decisions. There is no universally correct answer — the right choice depends on your team size, domain complexity, operational maturity, and growth trajectory. This topic provides a structured framework for making that decision.
Side-by-Side Comparison
| Dimension | Monolith | Microservices |
|---|---|---|
| Deployment | Single unit, all-or-nothing | Independent per service |
| Complexity | In-process, straightforward | Distributed, operationally complex |
| Data Consistency | ACID transactions easy | Eventual consistency, sagas required |
| Team Scaling | Contention as team grows | Teams own services independently |
| Technology | Single stack | Polyglot possible |
| Performance | In-process calls, fast | Network overhead per call |
| Testing | Integration tests simpler | Contract tests, E2E harder |
| Debugging | Stack traces, single process | Distributed tracing required |
The Monolith is Not the Enemy
Many successful companies run monoliths at scale. Shopify, Stack Overflow, and Basecamp all use monolithic architectures effectively. A well-structured monolith with clear module boundaries (a modular monolith) can deliver many benefits attributed to microservices while avoiding the distributed systems tax.
Monolith Advantages
- Simplicity: One codebase, one deployment, one database — dramatically simpler to develop, test, and debug
- Refactoring: Changing internal boundaries is an IDE refactor, not a distributed migration
- Transactions: ACID transactions span the entire application with no extra complexity
- Performance: In-process function calls are orders of magnitude faster than network calls
- Operational overhead: One service to monitor, deploy, and scale
Decision Framework
Use these questions to guide your architecture decision. If most answers point to microservices, consider it. If not, start with a well-structured monolith.
// Architecture decision scoring
interface DecisionFactor {
question: string;
monolithScore: number; // 1-5
microservicesScore: number; // 1-5
}
const decisionFactors: DecisionFactor[] = [
{
question: "Team size: How many developers?",
monolithScore: 5, // < 10 developers
microservicesScore: 2, // > 30 developers
},
{
question: "Domain complexity: How many distinct business areas?",
monolithScore: 4, // 1-3 areas
microservicesScore: 3, // 5+ distinct areas
},
{
question: "Deployment frequency: How often do you release?",
monolithScore: 3, // Weekly or less
microservicesScore: 4, // Multiple times daily, different cadences
},
{
question: "Scaling needs: Do components need different scaling?",
monolithScore: 4, // Uniform scaling is fine
microservicesScore: 2, // Components have very different resource needs
},
{
question: "Operational maturity: CI/CD, monitoring, IaC?",
monolithScore: 2, // Basic operations
microservicesScore: 5, // Advanced DevOps practices in place
},
];
function recommendArchitecture(scores: DecisionFactor[]): string {
const monoTotal = scores.reduce((s, f) => s + f.monolithScore, 0);
const microTotal = scores.reduce((s, f) => s + f.microservicesScore, 0);
if (monoTotal > microTotal + 5) return "Strong monolith recommendation";
if (microTotal > monoTotal + 5) return "Consider microservices";
return "Modular monolith — best of both worlds";
}
Migration Strategies
Most organizations do not start with microservices — they migrate to them. The key principle is incremental migration. Never attempt a big-bang rewrite. Instead, extract services one at a time from the monolith, starting with the parts that would benefit most from independence.
// Step 1: Identify the seam in the monolith
// Before: Everything in one module
class MonolithOrderService {
async placeOrder(order: OrderDTO): Promise {
// Validate inventory (tightly coupled)
const inventory = await this.inventoryRepo.check(order.items);
// Process payment (tightly coupled)
const payment = await this.paymentProcessor.charge(order.total);
// Save order
await this.orderRepo.save(order);
// Send notification (tightly coupled)
await this.emailService.sendConfirmation(order);
return { success: true, orderId: order.id };
}
}
// Step 2: Introduce an interface at the seam
interface PaymentPort {
charge(amount: number, customerId: string): Promise;
}
// Step 3: Create an internal adapter (still in monolith)
class InternalPaymentAdapter implements PaymentPort {
constructor(private readonly paymentProcessor: PaymentProcessor) {}
async charge(amount: number, customerId: string): Promise {
return this.paymentProcessor.charge(amount); // Calls existing code
}
}
// Step 4: Extract to a service and create an external adapter
class ExternalPaymentAdapter implements PaymentPort {
constructor(private readonly httpClient: HttpClient) {}
async charge(amount: number, customerId: string): Promise {
const response = await this.httpClient.post(
"http://payment-service/api/charges",
{ amount, customerId }
);
return response.data;
}
}
// Step 5: Switch adapters (feature flag or config)
function createPaymentPort(config: AppConfig): PaymentPort {
if (config.useExternalPaymentService) {
return new ExternalPaymentAdapter(new HttpClient());
}
return new InternalPaymentAdapter(new PaymentProcessor());
}
Migration Anti-Patterns
- Big bang rewrite: Attempting to rewrite the entire monolith as microservices at once — this almost always fails
- Shared database: Keeping a shared database between the monolith and extracted services creates tight coupling
- Distributed monolith: Extracting services that are still tightly coupled through synchronous chains
- Premature extraction: Extracting services before understanding the domain boundaries, leading to wrong splits
The Monolith-First Approach
Martin Fowler advocates starting with a monolith and extracting microservices as needed. This approach lets you discover the right boundaries through experience rather than guessing upfront. The key is to build the monolith with clean module boundaries so extraction is feasible when the time comes.
// Well-structured monolith module boundaries
// src/modules/orders/
// ├── OrderModule.ts (public API)
// ├── domain/ (internal)
// ├── services/ (internal)
// └── repositories/ (internal)
// OrderModule.ts - The only public API of the module
export class OrderModule {
constructor(
private readonly orderService: OrderService,
private readonly queryService: OrderQueryService
) {}
// Public methods other modules can call
async placeOrder(command: PlaceOrderCommand): Promise {
return this.orderService.placeOrder(command);
}
async getOrderSummary(orderId: OrderId): Promise {
return this.queryService.getSummary(orderId);
}
// Event handlers for inter-module communication
async onPaymentReceived(event: PaymentReceivedEvent): Promise {
await this.orderService.confirmPayment(event.orderId);
}
}
// Modules communicate through well-defined APIs,
// making future extraction to microservices straightforward
Key Takeaways
- Start simple: Begin with a modular monolith unless you have strong reasons not to
- Migrate incrementally: Extract services one at a time using the Strangler Fig pattern
- Invest in operations first: Before going micro, ensure you have CI/CD, monitoring, and container orchestration
- Revisit regularly: The right architecture evolves with your team, domain, and scale