caduh

Monolith vs Microservices — A Quick Comparison

4 min read

The practical trade‑offs: when a monolith (or modular monolith) is the right call, when microservices pay off, and how to evolve without pain.

TL;DR

  • Start with a modular monolith: one deployable app with clear internal boundaries (modules/packages). It’s fastest to ship and simplest to operate.
  • Move to microservices when you have team scale, clear bounded contexts, and a real need for independent deploys/scaling. Otherwise you trade code complexity for ops complexity.
  • Microservices require platform maturity (CI/CD, service discovery, observability, gateways, messaging) and disciplined data ownership (DB‑per‑service).
  • Evolve incrementally with a strangler approach: carve off one capability at a time behind stable interfaces.

Mental model (60 seconds)

Monolith:            [ Web + API + Jobs + DB ]  (single codebase & deploy)
Modular Monolith:    [ Web | API | Jobs ]       (modules)  →  [ DB ]
Microservices:       [ Web ] [ Orders ] [ Billing ] [ Search ] ... (many services)
                       │        │          │
                   (gateway)  (events)   (DB‑per‑service)
  • Monolith: one unit to build, test, deploy. Simple debugging; easy transactions. Can get big, but still manageable with modules.
  • Microservices: many small, independently deployable services talking over the network (sync/async). Enables team autonomy, but adds latency, retries, versioning, and operational overhead.

Quick comparison

| Aspect | Monolith / Modular Monolith | Microservices | |---|---|---| | Deploy unit | One | Many (per service) | | Scaling | Whole app or by process type | Per service (right‑size resources) | | Consistency | In‑process, ACID easy | Often eventual, needs sagas/outbox | | Latency | In‑process calls (fast) | Network hops; need timeouts/retries | | Testing | Simple end‑to‑end | Contract tests, test envs, mocks | | Failure modes | Process failure | Partial failures, cascading risk | | Team autonomy | Shared repo/process | Independent releases per team | | Local dev | Run app + DB | Orchestrate many deps; use mocks/dev‑containers | | Ops cost | Lower (fewer moving parts) | Higher (observability, infra, oncall) |


When a monolith is the better choice

  • Small team or new product where speed trumps everything.
  • Domain still changing; you need cross‑cutting refactors without network boundaries.
  • Heavy transactional workflows (multi‑object ACID).
  • Limited ops budget or platform maturity.

Make it modular: define packages/modules per bounded context (e.g., Orders, Billing, Catalog). Keep clear interfaces even inside one repo so extraction later is natural.


When microservices pay off

  • Multiple teams stepping on each other in one codebase; need independent deploy cadence.
  • Uneven scaling (e.g., search or media processing dwarf everything else).
  • Reliability isolation: a bad feature shouldn’t take the site down.
  • Compliance/tenancy boundaries (data separation, geo).

Prereqs:

  • Automated CI/CD, service discovery, centralized config, and observability (logs/metrics/traces).
  • API gateway for auth, rate limiting, CORS, and routing.
  • Messaging (events/queues) and patterns like Outbox, Idempotency, Sagas.

Data & integration

  • Database‑per‑service. Avoid shared tables across services. Share events/APIs, not schemas.
  • Prefer async integration for cross‑service workflows: publish domain events (OrderCreated) and react elsewhere.
  • For multi‑step consistency use Sagas (orchestration or choreography) + Outbox to emit events atomically with state changes.

Communication patterns (picks that age well)

  • External clients → API Gateway → services (REST/gRPC).
  • Service↔Service:
    • Sync: gRPC/HTTP with timeouts, retries, circuit breakers.
    • Async: Kafka/SNS/SQS/NATS; consumers must be idempotent.
  • Don’t be chatty: design coarse‑grained APIs to avoid N‑shot network waterfalls.

Migration playbook (strangler pattern)

  1. Draw boundaries inside your monolith (modules / contexts).
  2. Put a stable interface in front of the module (internal API).
  3. Extract one module behind the same interface (proxy calls to the new service).
  4. Move data ownership (new DB), publish events; leave anti‑corruption adapters where needed.
  5. Repeat. Track latency, error budgets, and oncall load to ensure it’s worth it.

Pitfalls & fixes

| Pitfall | Why it hurts | Fix | |---|---|---| | Distributed monolith (tight coupling across services) | Change still touches many repos | Strong boundaries; fewer, larger services; versioned contracts | | Too many tiny services | Ops overhead explodes | Start with modular monolith → extract only hot contexts | | Sync chains on the request path | High tail latency, failure cascades | Use async events, caches, bulkheads, timeouts | | Shared DB across services | Hidden coupling, lock contention | DB‑per‑service; read models for cross‑views | | Inconsistent auth/observability | Debugging hell | Gateway + correlation IDs + centralized tracing | | No contract tests | Runtime breakage | Add consumer‑driven contracts (Pact, etc.) |


Tiny examples

API gateway route → monolith (today), microservice (tomorrow)

location /api/orders/ {
  proxy_pass http://monolith:4000;   # later: http://orders:4001
  proxy_set_header X-Request-Id $request_id;
}

Outbox (pattern sketch)

-- within the same DB tx as the state change
INSERT INTO orders(id, status, ...);
INSERT INTO outbox(event_type, payload, created_at) VALUES ('OrderCreated', '{"id":"..."}', now());
-- background worker relays outbox → Kafka/SNS (idempotent)

Decision guide (fast)

  • Single team, fast changes, strong transactions?Modular monolith.
  • Multiple teams, uneven scale, need isolation?Microservices (with platform in place).
  • Unclear boundaries? → Stay monolith; refine domain first.

Quick checklist

  • [ ] Start modular: bounded contexts as packages/modules.
  • [ ] Enforce internal interfaces; no leaking across modules.
  • [ ] Add observability early (logs/metrics/traces, request IDs).
  • [ ] If going microservices: DB‑per‑service, gateway, messaging, timeouts/retries, circuit breakers.
  • [ ] Prefer async for cross‑service workflows; ensure idempotency.
  • [ ] Migrate with strangler slices; measure latency, cost, and oncall impact.

One‑minute adoption plan

  1. Carve your monolith into clean modules with interfaces.
  2. Add request IDs and distributed tracing even in the monolith.
  3. Extract one high‑value module behind a gateway route; keep behavior identical.
  4. Introduce events/outbox for cross‑module workflows.
  5. Standardize service templates (health checks, metrics, logging) before extracting more.