Chapter 116 β Monolith vs Microservices
π Definitions
- Monolith β a single deployable application containing all features.
- Microservices β multiple small, independently deployable services that communicate over the network.
- Modular monolith β single deployable, but internally organized into clear module boundaries (the often-recommended middle ground).
π Comparison
| Aspect | Monolith | Microservices |
|---|---|---|
| Codebase | Single | Many |
| Deployment | All-at-once | Independent |
| Communication | In-process | Network (HTTP, gRPC, queues) |
| Database | Usually one | Each service owns its DB (ideally) |
| Transactions | ACID native | Distributed sagas, complex |
| Team org | One team / multiple feature teams | One team per service |
| Operability | Easy logs, debugging | Needs distributed tracing, central logs |
| Failure isolation | One bug can take down the whole thing | One service can be down while others run |
| Best for | Small/medium teams, early product | Large orgs, independent domains |
| Pitfalls | Slow CI, deploy coupling | Network failures, operational overhead |
ποΈ When to Start with Monolith
The honest answer most interviewers want: "I'd start with a well-modularized monolith. Microservices add operational cost β networking, observability, deployment, data consistency. Only split when team size or scale forces it."
ποΈ Modular Monolith Structure
src/
βββ modules/
β βββ auth/
β β βββ auth.controller.js
β β βββ auth.service.js
β β βββ auth.repo.js
β β βββ auth.routes.js
β βββ orders/
β β βββ orders.controller.js
β β βββ orders.service.js
β β βββ orders.repo.js
β β βββ orders.routes.js
β βββ billing/
β β βββ ...
β βββ notifications/
β βββ ...
βββ shared/
β βββ db/
βββ app.jsEach module exposes a clear interface (a service, not its DB). When you eventually split it out, you replace in-process calls with network calls.
π» Code Example β Module Boundary
// modules/orders/orders.service.js
import { BillingService } from "../billing/billing.service.js";
export const OrderService = {
async create(dto) {
const order = await OrderRepo.create(dto);
await BillingService.charge(order); // in-process today
// Future: an HTTP/gRPC call or a queue message
return order;
},
};If/when you split billing into its own service, only this one line changes:
await fetch("http://billing/api/charge", { method: "POST", body: JSON.stringify(order) });π Inter-Service Communication
| Style | Use |
|---|---|
| Sync HTTP/REST | Simple request-response |
| Sync gRPC | Strongly-typed, lower latency |
| Async events (Kafka, RabbitMQ, SNS) | Decoupling, eventual consistency |
| Message queues (BullMQ, SQS) | Background work, retry, fan-out |
π» Code Example β Event-Driven (Decoupling)
// Order service emits an event
await eventBus.publish("OrderPaid", { orderId: order.id, userId: user.id, amount });
// Notification service consumes (separate process, separate deploy)
eventBus.subscribe("OrderPaid", async (event) => {
await sendEmail(event.userId, `Order ${event.orderId} paid`);
});
// Analytics service also subscribes β no change to Order service needed
eventBus.subscribe("OrderPaid", async (event) => {
await analytics.track("order_paid", event);
});This is the strongest argument for microservices: adding consumers doesn't touch the producer.
π₯ Microservices Pitfalls
1. Distributed Transactions
You can't BEGINβ¦COMMIT across services. You need:
- Sagas (compensating transactions): if step 3 fails, undo steps 1 and 2.
- Outbox pattern: write to DB and to an "outbox" table in the same transaction; a background process publishes outbox events.
2. Network Failures
Every call can fail or be slow. Use:
- Timeouts
- Retries with exponential backoff
- Circuit breakers (e.g.,
opossum) - Fallbacks / degraded responses
3. Observability
With 20 services, you can't tail -f 20 logs. Required:
- Centralized logs (ELK, Loki, Datadog).
- Distributed tracing (OpenTelemetry) β trace IDs across services.
- Metrics dashboards (Prometheus + Grafana).
4. Data Consistency
Each service owns its DB β you can't JOIN. Approaches:
- Materialized views built from events.
- API composition (the gateway calls multiple services and merges).
- Accept eventual consistency.
5. Versioning
Service A v2 may need to coexist with v1 callers. Strategies:
- Backwards-compatible changes only.
- Versioned endpoints (
/v1,/v2). - Proto evolution rules for gRPC.
π When to Actually Split
Honest triggers:
- Team size > ~30 engineers β deployment coordination becomes painful.
- One module needs a different scaling profile (e.g., image processing).
- Different release cadences β one team ships hourly, another monthly.
- Different tech needs β one part really wants Rust/Python.
If none of those apply, stay monolithic.
π― Likely Interview Questions
- Would you build this as a monolith or microservices?
- What's the modular monolith?
- How do you handle distributed transactions?
- What's the outbox pattern?
- How do you trace a request across services? β Propagate a trace/correlation ID via HTTP header (
traceparent) and log it everywhere; aggregate with OpenTelemetry.