The Architecture Decision That Shapes Everything
Choosing between a monolith and microservices is not a technology decision. It is an organizational decision. It determines how your teams communicate, how fast you ship, how you debug production issues, and how much infrastructure you manage.
Most teams get this wrong by defaulting to microservices too early or clinging to a monolith too long. This article walks through both architectures in depth, shows you when each makes sense, and gives you practical migration strategies for when you need to evolve.
Monolithic Architecture
A monolith is a single deployable unit. All your application logic, data access, business rules, and UI rendering live in one codebase and deploy as one artifact.
ββββββββββββββββββββββββββββββββββββββββββββββββ
β MONOLITH β
β β
β ββββββββββββ ββββββββββββ ββββββββββββ β
β β Auth β β Orders β β Payments β β
β β Module β β Module β β Module β β
β ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ β
β β β β β
β ββββββΌβββββββββββββββΌβββββββββββββββΌββββββ β
β β Shared Data Layer β β
β ββββββββββββββββββ¬ββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββΌββββββββββββββββββββββββ β
β β Single Database β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββ
Structure of a Typical Monolith
In a well-structured monolith, code is organized into modules or packages within the same repository. Here is a typical Node.js monolith structure:
my-app/
βββ src/
β βββ modules/
β β βββ auth/
β β β βββ auth.controller.ts
β β β βββ auth.service.ts
β β β βββ auth.repository.ts
β β β βββ auth.types.ts
β β βββ orders/
β β β βββ orders.controller.ts
β β β βββ orders.service.ts
β β β βββ orders.repository.ts
β β β βββ orders.types.ts
β β βββ payments/
β β βββ payments.controller.ts
β β βββ payments.service.ts
β β βββ payments.repository.ts
β β βββ payments.types.ts
β βββ shared/
β β βββ database.ts
β β βββ middleware.ts
β β βββ utils.ts
β βββ app.ts
βββ package.json
βββ Dockerfile
Modules communicate through direct function calls. There is no network boundary between them.
// orders.service.ts β direct import, no network call
import { PaymentService } from '../payments/payments.service';
import { AuthService } from '../auth/auth.service';
export class OrderService {
constructor(
private paymentService: PaymentService,
private authService: AuthService,
) {}
async createOrder(userId: string, items: OrderItem[]) {
// Direct function call β same process, same memory space
const user = await this.authService.getUser(userId);
const total = this.calculateTotal(items);
const payment = await this.paymentService.charge(user, total);
return this.orderRepository.save({
userId,
items,
total,
paymentId: payment.id,
});
}
private calculateTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
Monolith Advantages
Simplicity in development. One repo, one build, one deploy. New developers clone a single repository and can run the entire application locally. There is no need to set up service meshes, configure inter-service authentication, or manage distributed tracing.
Transaction integrity. Because everything shares a single database, you get ACID transactions for free. Creating an order, charging a payment, and updating inventory can all happen in one database transaction.
async createOrderWithPayment(userId: string, items: OrderItem[]) {
// Single transaction β all or nothing
return this.db.transaction(async (trx) => {
const order = await trx('orders').insert({ userId, items });
const payment = await trx('payments').insert({
orderId: order.id,
amount: order.total,
});
await trx('inventory').decrement('stock', items);
return { order, payment };
});
}
Easy debugging. A stack trace shows you the entire call chain. No need to correlate logs across services or trace request IDs through multiple systems.
Lower operational overhead. One server, one deployment pipeline, one set of logs. You do not need Kubernetes, service discovery, or distributed monitoring on day one.
Monolith Disadvantages
Scaling is all-or-nothing. If your image processing module needs 10x more CPU, you have to scale the entire application. You cannot scale individual components independently.
Deployment risk. Every deployment ships the entire application. A bug in the payment module means rolling back the auth and orders modules too, even if they had no changes.
Technology lock-in. The entire application uses one language, one framework, one version of every dependency. Upgrading a major dependency requires testing the entire system.
Growing complexity. As the codebase grows past 100,000 lines, build times increase, test suites slow down, and module boundaries blur as developers take shortcuts.
Team bottlenecks. Multiple teams working on the same codebase leads to merge conflicts, coordinated releases, and the inability to ship independently.
Microservices Architecture
Microservices decompose your application into small, independently deployable services. Each service owns its data, runs in its own process, and communicates with other services over the network.
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Auth Svc β β Orders Svc β β Payment Svc β
β β β β β β
β βββββββββ β β βββββββββ β β βββββββββ β
β β API β β β β API β β β β API β β
β βββββββββ€ β β βββββββββ€ β β βββββββββ€ β
β β Logic β β β β Logic β β β β Logic β β
β βββββββββ€ β β βββββββββ€ β β βββββββββ€ β
β β DB β β β β DB β β β β DB β β
β βββββββββ β β βββββββββ β β βββββββββ β
ββββββββ¬βββββββ ββββββββ¬βββββββ ββββββββ¬βββββββ
β β β
βββββββββββββ¬ββββββββββββββββββββββββββββ
β
βββββββββΌββββββββ
β API Gateway β
βββββββββ¬ββββββββ
β
βββββββββΌββββββββ
β Client β
βββββββββββββββββ
Service Communication
Services communicate via HTTP/REST, gRPC, or asynchronous messaging. This is the fundamental difference from a monolith β calls that were once in-process function calls now cross a network boundary.
Synchronous communication (HTTP/gRPC):
// orders.service.ts β HTTP call to payment service
import axios from 'axios';
export class OrderService {
private readonly paymentServiceUrl = process.env.PAYMENT_SERVICE_URL;
async createOrder(userId: string, items: OrderItem[]) {
// Network call β different process, potentially different machine
const paymentResponse = await axios.post(
`${this.paymentServiceUrl}/api/payments`,
{
userId,
amount: this.calculateTotal(items),
},
{
timeout: 5000,
headers: { 'X-Request-Id': requestId },
}
);
if (!paymentResponse.data.success) {
throw new PaymentFailedError(paymentResponse.data.error);
}
return this.orderRepository.save({
userId,
items,
paymentId: paymentResponse.data.paymentId,
});
}
}
Asynchronous communication (message queue):
// orders.service.ts β publish event instead of direct call
import { MessageBroker } from './messaging';
export class OrderService {
async createOrder(userId: string, items: OrderItem[]) {
const order = await this.orderRepository.save({ userId, items });
// Publish event β payment service subscribes and processes
await MessageBroker.publish('order.created', {
orderId: order.id,
userId,
amount: order.total,
items,
});
return order; // Payment happens asynchronously
}
}
// payment.consumer.ts β in the payment service
MessageBroker.subscribe('order.created', async (event) => {
await paymentService.processPayment({
orderId: event.orderId,
userId: event.userId,
amount: event.amount,
});
});
Microservices Advantages
Independent deployment. Ship the payment service without touching orders or auth. Teams deploy on their own schedule. A failed deployment affects only one service.
Independent scaling. Scale the search service to handle Black Friday traffic while keeping the user profile service at normal capacity.
Technology freedom. Write the real-time notification service in Go for performance, the recommendation engine in Python for ML libraries, and the CRUD API in Node.js for development speed.
Team autonomy. Each team owns a service end-to-end. They choose their tech stack, set their release cadence, and operate their service independently.
Fault isolation. If the recommendation service crashes, users can still browse, search, and purchase. The system degrades gracefully instead of going completely down.
Microservices Disadvantages
Distributed system complexity. Network calls fail, time out, and return stale data. You must handle retries, circuit breakers, timeouts, and partial failures that simply do not exist in a monolith.
// Circuit breaker pattern β necessary in microservices
import CircuitBreaker from 'opossum';
const paymentBreaker = new CircuitBreaker(callPaymentService, {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 30000,
});
paymentBreaker.fallback(() => ({
status: 'pending',
message: 'Payment will be processed shortly',
}));
paymentBreaker.on('open', () => {
logger.warn('Payment service circuit breaker opened');
alerting.notify('payment-service-degraded');
});
Data consistency. Without shared transactions, you need sagas, eventual consistency, and compensating transactions. This is significantly harder to implement and reason about.
Operational overhead. You need container orchestration (Kubernetes), service discovery, distributed tracing, centralized logging, and health monitoring for every service.
Testing complexity. Integration tests require running multiple services. End-to-end tests are slower and flakier because they depend on network calls between services.
Debugging difficulty. A single user request might touch 5 services. You need distributed tracing (Jaeger, Zipkin) and correlated logging to follow a request through the system.
Comparison Table
| Aspect | Monolith | Microservices |
|---|---|---|
| Deployment | Single artifact, all-or-nothing | Independent per service |
| Scaling | Scale entire application | Scale individual services |
| Data consistency | ACID transactions | Eventual consistency, sagas |
| Development speed (small team) | Faster | Slower (infrastructure overhead) |
| Development speed (large team) | Slower (merge conflicts, coordination) | Faster (team autonomy) |
| Debugging | Stack traces, single process | Distributed tracing required |
| Technology choice | Single stack | Polyglot possible |
| Operational cost | Lower | Significantly higher |
| Fault isolation | One bug can crash everything | Failures contained per service |
| Team structure | Shared codebase | Service ownership |
| Latency | In-process calls (nanoseconds) | Network calls (milliseconds) |
| Testing | Straightforward | Complex (contract tests, integration) |
When to Split a Monolith
Do not split a monolith because microservices are trendy. Split when you have specific, measurable pain points.
Strong Signals to Split
-
Deployment frequency is blocked. Teams cannot ship because they are waiting on other teams to finish their changes, stabilize shared code, or coordinate release windows.
-
Scaling requirements diverge. One module needs 20 instances to handle load while the rest need 2. You are paying 10x the infrastructure cost to scale one feature.
-
Technology constraints. A specific module would benefit dramatically from a different language, framework, or database, but the monolith makes that impossible.
-
Build and test times are painful. The CI pipeline takes 45 minutes. Running the full test suite takes an hour. Developers context-switch during builds.
-
Team size exceeds 8-10 per module. At this point, communication overhead within the monolith starts to outweigh the simplicity benefit.
Weak Signals (Do Not Split Yet)
- "Everyone else is doing microservices"
- "We want to put Kubernetes on our resume"
- "Our codebase is messy" (fix the monolith first)
- Team size under 10 total developers
- No clear domain boundaries identified
Migration Strategies
The Strangler Fig Pattern
Named after the strangler fig tree that grows around a host tree until the host dies, this pattern lets you gradually replace monolith functionality with microservices.
Phase 1: Route through proxy
ββββββββββ βββββββββββββ ββββββββββββ
β Client ββββββΆβ Proxy ββββββΆβ Monolith β
ββββββββββ βββββββββββββ ββββββββββββ
Phase 2: Extract first service
ββββββββββ βββββββββββββ ββββββββββββ
β Client ββββββΆβ Proxy ββββββΆβ Monolith β
ββββββββββ βββββββ¬ββββββ ββββββββββββ
β
β /api/auth/*
βΌ
βββββββββββββ
β Auth Svc β
βββββββββββββ
Phase 3: Extract more services
ββββββββββ βββββββββββββ ββββββββββββ
β Client ββββββΆβ Proxy ββββββΆβ Monolith β
ββββββββββ ββββ¬βββ¬βββ¬βββ ββββββββββββ
β β β (shrinking)
ββββββββ β ββββββββ
βΌ βΌ βΌ
ββββββββββ ββββββββββ ββββββββββ
β Auth β β Orders β βPayment β
ββββββββββ ββββββββββ ββββββββββ
Phase 4: Monolith eliminated
ββββββββββ βββββββββββββ
β Client ββββββΆβ Proxy β
ββββββββββ ββββ¬βββ¬βββ¬βββ
ββββββββ β ββββββββ
βΌ βΌ βΌ
ββββββββββ ββββββββββ ββββββββββ
β Auth β β Orders β βPayment β
ββββββββββ ββββββββββ ββββββββββ
Implementation using an API gateway (NGINX example):
# nginx.conf β strangler fig routing
upstream monolith {
server monolith-app:3000;
}
upstream auth_service {
server auth-service:3001;
}
upstream orders_service {
server orders-service:3002;
}
server {
listen 80;
# Extracted: route to new auth service
location /api/auth/ {
proxy_pass http://auth_service;
}
# Extracted: route to new orders service
location /api/orders/ {
proxy_pass http://orders_service;
}
# Everything else: still goes to monolith
location / {
proxy_pass http://monolith;
}
}
Branch by Abstraction
When you cannot easily route at the network level, use branch by abstraction within the monolith itself.
// Step 1: Create abstraction layer
type NotificationSender = {
send(userId: string, message: string): Promise<void>;
};
// Step 2: Existing monolith implementation
class InternalNotificationSender implements NotificationSender {
async send(userId: string, message: string) {
await this.emailModule.send(userId, message);
await this.pushModule.send(userId, message);
}
}
// Step 3: New microservice implementation
class ExternalNotificationSender implements NotificationSender {
async send(userId: string, message: string) {
await axios.post(`${NOTIFICATION_SERVICE_URL}/send`, {
userId,
message,
});
}
}
// Step 4: Feature flag to switch
const notificationSender: NotificationSender =
featureFlags.isEnabled('use-notification-service')
? new ExternalNotificationSender()
: new InternalNotificationSender();
Database Decomposition
The hardest part of splitting a monolith is separating the shared database. This must be done incrementally.
Phase 1: Shared database (monolith)
ββββββββββββββββββββββββββββββββ
β Monolith DB β
β βββββββββ βββββββββ ββββββ β
β β users β βorders β βpay β β
β βββββββββ βββββββββ ββββββ β
ββββββββββββββββββββββββββββββββ
Phase 2: Logical separation (same DB, separate schemas)
ββββββββββββββββββββββββββββββββ
β Monolith DB β
β ββββββββββββββ ββββββββββββ β
β β auth_schemaβ βorders_schβ β
β β ββββββββ β β ββββββββ β β
β β βusers β β β βordersβ β β
β β ββββββββ β β ββββββββ β β
β ββββββββββββββ ββββββββββββ β
ββββββββββββββββββββββββββββββββ
Phase 3: Physical separation (separate databases)
ββββββββββββ ββββββββββββ ββββββββββββ
β Auth DB β βOrders DB β β Pay DB β
β ββββββββ β β ββββββββ β β ββββββββ β
β βusers β β β βordersβ β β β pay β β
β ββββββββ β β ββββββββ β β ββββββββ β
ββββββββββββ ββββββββββββ ββββββββββββ
Data Consistency Challenges
In a monolith, you wrap related operations in a database transaction. In microservices, each service has its own database. You cannot use a single transaction across databases.
The Saga Pattern
A saga is a sequence of local transactions where each step publishes an event that triggers the next step. If a step fails, compensating transactions undo previous steps.
// Choreography-based saga β each service reacts to events
// Order Service
async function handleOrderCreated(order: Order) {
await orderRepo.save({ ...order, status: 'PENDING' });
await publish('order.created', { orderId: order.id, amount: order.total });
}
// Payment Service β listens for order.created
async function handleOrderCreated(event: OrderCreatedEvent) {
try {
const payment = await processPayment(event.amount);
await publish('payment.completed', {
orderId: event.orderId,
paymentId: payment.id,
});
} catch (error) {
await publish('payment.failed', {
orderId: event.orderId,
reason: error.message,
});
}
}
// Inventory Service β listens for payment.completed
async function handlePaymentCompleted(event: PaymentCompletedEvent) {
try {
await reserveInventory(event.orderId);
await publish('inventory.reserved', { orderId: event.orderId });
} catch (error) {
// Compensating transaction: refund payment
await publish('inventory.failed', {
orderId: event.orderId,
paymentId: event.paymentId,
});
}
}
// Order Service β listens for inventory.reserved
async function handleInventoryReserved(event: InventoryReservedEvent) {
await orderRepo.update(event.orderId, { status: 'CONFIRMED' });
}
// Order Service β listens for payment.failed
async function handlePaymentFailed(event: PaymentFailedEvent) {
await orderRepo.update(event.orderId, { status: 'CANCELLED' });
}
Eventual Consistency
Microservices embrace eventual consistency. Data across services will converge to a consistent state, but there will be windows where services have stale or conflicting data.
Strategies to manage eventual consistency:
- Idempotent operations β Processing the same message twice produces the same result
- Correlation IDs β Track a request across services for debugging
- Read-your-own-writes β After a write, read from the same service, not a replica
- Compensating transactions β Undo actions when a downstream step fails
// Idempotent payment processing
async function processPayment(event: PaymentRequestEvent) {
// Check if already processed using idempotency key
const existing = await paymentRepo.findByIdempotencyKey(event.requestId);
if (existing) {
return existing; // Already processed, return existing result
}
const payment = await chargeCard(event.amount, event.cardToken);
await paymentRepo.save({ ...payment, idempotencyKey: event.requestId });
return payment;
}
Team Structure and Conway's Law
"Any organization that designs a system will produce a design whose structure is a copy of the organization's communication structure." β Melvin Conway
Conway's Law is not just an observation β it is a force. Your architecture will mirror your team structure whether you plan for it or not.
Monolith Team Structure
βββββββββββββββββββββββββββββββββββββββββββ
β Engineering Team β
β β
β Frontend Backend DBA QA DevOps β
β Devs Devs β
β β
β Everyone works on the same codebase β
βββββββββββββββββββββββββββββββββββββββββββ
Works well for small teams (under 15 engineers). Communication is easy, shared context is high, and cross-functional work happens naturally.
Microservices Team Structure
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β Auth Team β β Orders Team β β Payment Team β
β β β β β β
β FE + BE + β β FE + BE + β β FE + BE + β
β QA + DevOps β β QA + DevOps β β QA + DevOps β
β β β β β β
β Owns: Auth β β Owns: Ordersβ β Owns: Pay β
β service, β β service, β β service, β
β auth DB, β β orders DB, β β pay DB, β
β auth tests β β order tests β β pay tests β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
Each team is cross-functional and owns their service end-to-end: development, testing, deployment, monitoring, and on-call. This is the "you build it, you run it" model.
The Inverse Conway Maneuver
If you want microservices, restructure your teams first to match the desired architecture. Do not split the monolith and then try to reorganize teams β the team structure will fight the architecture.
Step 1: Identify domain boundaries
Step 2: Form cross-functional teams around those domains
Step 3: Let each team own their domain in the monolith
Step 4: When ready, extract each domain into a service
The Modular Monolith β The Middle Ground
Before jumping to microservices, consider a modular monolith. It gives you clear module boundaries, encapsulated data access, and defined interfaces between modules β all within a single deployable unit.
// Modular monolith β modules communicate through defined interfaces only
// modules/orders/public-api.ts β only this is importable by other modules
export type { OrderSummary } from './types';
export { getOrderSummary } from './queries';
export { createOrder } from './commands';
// modules/orders/internal/ β private to the orders module
// Other modules CANNOT import from here
// Enforced by linting rules or build-time checks
// modules/payments/payment.service.ts
import { getOrderSummary } from '../orders/public-api';
// NOT: import { OrderRepository } from '../orders/internal/repository';
This gives you the simplicity of a monolith with the organizational clarity of microservices. When a module is ready to become a service, the public API becomes the network API.
Decision Framework
Use this flowchart to decide your architecture:
START
β
βΌ
Team size > 20 engineers? ββNOβββΆ Monolith (or modular monolith)
β
YES
β
βΌ
Clear domain boundaries? ββNOβββΆ Modular monolith, identify domains first
β
YES
β
βΌ
Need independent scaling? ββNOβββΆ Modular monolith
β
YES
β
βΌ
Have DevOps maturity? ββNOβββΆ Modular monolith, build platform first
(CI/CD, monitoring,
container orchestration)
β
YES
β
βΌ
MICROSERVICES
Key Takeaways
- Start with a monolith unless you have proven reasons not to. You can always extract services later.
- The strangler fig pattern is the safest migration path. Never do a "big bang" rewrite.
- Microservices trade development simplicity for operational complexity. Make sure your organization is ready for that trade.
- Conway's Law is real. Restructure teams before restructuring architecture.
- A modular monolith gives you 80% of the organizational benefits with 20% of the operational overhead.
- Data consistency across services is the hardest problem. Understand sagas and eventual consistency before committing to microservices.
- The right architecture depends on your team size, domain complexity, scaling requirements, and operational maturity β not on what FAANG companies use.