Skip to main content
Architecture & System Design
📐 ArchitectureLesson 7 of 13

Monolith vs Services

Monolith vs microservices — and why 'micro' is usually the wrong call for you.

Monolith vs Services

Stage 3 · Architecture & System Design · B.U.I.L.D. letter: U

You've shipped a working app. Someone on Twitter says microservices are "the industry standard." You spend three weeks splitting your app into eight services. Now nothing works and you spend your days debugging network timeouts between services that used to be a single function call. This lesson is the antidote.


⚠️ The vibe trap

The word "microservices" sounds serious, scalable, and professional — exactly what a real engineer would use. So you split your app before you've even validated the product, before you have more than one developer, before you have a single scaling problem. Now you have eight repos, eight deployment pipelines, eight things that can fail at 2 a.m., and a debugging experience that requires tracing a request across six log streams just to find a typo in a field name. Complexity you didn't earn is complexity that eats you alive. Start with a monolith. Split later, only if you must.


🏛️ What a monolith actually is

A monolith is one deployable unit. Your entire application — API routes, business logic, database access, background jobs — lives in one process and deploys together. That sounds limiting. It is actually a superpower at every scale below "we have 50 engineers and three independent product lines."

MONOLITH ARCHITECTURE
─────────────────────────────────────────────────────
  Browser / Mobile client
        │
        ▼
  ┌─────────────────────────────────────────────┐
  │              Your App (one process)          │
  │                                             │
  │  ┌──────────┐  ┌──────────┐  ┌──────────┐  │
  │  │  Routes  │→ │ Business │→ │   Data   │  │
  │  │  (HTTP)  │  │  Logic   │  │  Access  │  │
  │  └──────────┘  └──────────┘  └──────────┘  │
  │         ↓              ↓              ↓      │
  │  ┌──────────────────────────────────────┐   │
  │  │        Background Jobs / Queue       │   │
  │  └──────────────────────────────────────┘   │
  └─────────────────────────────────────────────┘
        │
        ▼
  ┌─────────────┐
  │  Database   │
  └─────────────┘

One deploy. One log stream. One database transaction.

Why this is the right default:

  • One deploy — you push one thing. It either works or it doesn't.
  • Easy local devnpm run dev and you have everything running.
  • Real transactions — your entire operation succeeds or rolls back atomically. No "partially completed distributed transaction" disasters.
  • Simple debugging — one log stream, one stack trace, one place to add a breakpoint.
  • Shared memory — modules can call each other directly. No serialization, no network, no timeouts.

Common mistake: Thinking "monolith" means "big ball of mud with no structure." It doesn't. A well-structured monolith is clean, fast, and easy to reason about.


📦 The modular monolith — the best of both worlds

You can have clean internal boundaries without distributing your app across the network. A modular monolith organizes code into self-contained modules (users, billing, content, notifications) that own their own logic and data access — but they still deploy as one unit.

MODULAR MONOLITH (internal structure)
─────────────────────────────────────────────────────
  ┌──────────────────────────────────────────────────┐
  │                   App Process                    │
  │                                                  │
  │  ┌────────────┐   ┌────────────┐                 │
  │  │   Users    │   │  Billing   │                 │
  │  │  module    │   │  module    │                 │
  │  │ ─────────  │   │ ─────────  │                 │
  │  │ user.ts    │   │ invoice.ts │                 │
  │  │ auth.ts    │   │ payment.ts │                 │
  │  │ userRepo   │   │ billRepo   │                 │
  └──┤────────────┼───┼────────────┼─────────────────┘
     │            │   │            │
     ▼            ▼   ▼            ▼
  ┌───────────────────────────────────┐
  │         Shared Database           │
  │  (each module owns its tables)    │
  └───────────────────────────────────┘

Modules communicate through defined interfaces (function calls),
NOT through shared tables or global state.

The discipline: Modules import from each other through a public interface — never by reaching into another module's internals. import { getUser } from '../users/api' is fine. import { db } from '../users/db' is not. This boundary discipline is what makes a modular monolith splittable later if you ever need to.

Mental model: Think of modules as rooms in a house. Each room has a door (the public API). You knock and use the door — you don't knock out the wall.

Common mistake: Letting modules share database tables directly. If billing queries the users table directly instead of calling users.getById(), you've lost the boundary entirely.


🕸️ What microservices actually cost

Before you choose microservices, you need to stare at the real bill. This is not a horror story — it's just accounting.

MICROSERVICES ARCHITECTURE
─────────────────────────────────────────────────────
  Browser / Mobile client
        │
        ▼
  ┌───────────┐
  │  API GW   │  ← you now operate this
  └───────────┘
   ↙    ↓    ↘
  [Users] [Billing] [Content]   ← 3 separate deploys
     ↓        ↓        ↓
  [DB-U]   [DB-B]   [DB-C]     ← 3 separate databases

To complete ONE user action (e.g. "publish a paid post"):
  API GW → Content Service (HTTP) → Billing Service (HTTP) → Users Service (HTTP)

If ANY hop fails mid-way, you have partial state across 3 databases.
You now need: retries, timeouts, distributed tracing, a saga pattern,
dead-letter queues, and a 3 a.m. on-call rotation.
CostMonolithMicroservices
Local developmentnpm run devDocker Compose with 6+ containers, or stub everything
Deploying a featurePush one repoCoordinate across N repos, possibly in order
Database transactionsNative ACIDDistributed — you must implement sagas or 2PC yourself
Debugging a bugOne log stream, one stack traceDistributed tracing (Jaeger, Datadog) required
Network failuresAlmost none (in-process)Every inter-service call can timeout or fail
Ops overheadOne app to monitorN apps, N databases, N health checks, N pipelines
Onboarding a new devClone one repoClone N repos, learn N deployment pipelines

Common mistake: Assuming microservices are automatically more reliable. They introduce more failure points. Netflix-level resilience patterns exist because microservices require them, not because they're inherently safer.


🚀 When services genuinely help

There are real scenarios where extracting a service pays off. Every one of them requires that you've already shipped, already have users, and already feel the specific pain the split solves.

VALID REASONS TO EXTRACT A SERVICE

1. INDEPENDENT SCALING
   Your video-transcoding module needs 40 CPU cores at peak.
   Your API needs 2. Keeping them in one process wastes money.

   [API — 2 cores × 3 instances]
   [Transcoder — 40 cores × 1 instance]  ← extract this

2. TEAM AUTONOMY
   You have 3 teams of 10 engineers each.
   Every deploy requires everyone to coordinate.
   Extract along team ownership lines — now each team ships independently.

3. DIFFERENT RUNTIME REQUIREMENTS
   Your ML inference module needs Python + GPU.
   Your API is Node.js. They genuinely cannot share a process.

4. COMPLIANCE ISOLATION
   Payment data must live in a PCI-DSS-compliant environment,
   completely isolated from the rest of the app.

The test: If you can't finish this sentence with a real, felt problem — "We need to extract this service because we are currently experiencing ___" — you don't need a service yet.

Common mistake: Extracting services for team independence before you have multiple teams. One developer does not need team autonomy.


✂️ How to split later if you must

If you've been disciplined about your module boundaries, splitting a module into a service is mechanical, not heroic. The modular monolith was practice for this moment.

EXTRACTING A SERVICE (the safe path)

Step 1: Confirm the module has a clean boundary
  - It has a public interface file (users/api.ts)
  - It owns its own tables (no other module queries them directly)
  - Its callers only use the public interface

Step 2: Replace in-process calls with an HTTP/gRPC client
  - Keep the same function signature in the interface
  - The caller doesn't know or care that it's now over the network

  BEFORE:  import { getUser } from '../users/api'
  AFTER:   import { getUser } from '../users/client'  // HTTP client, same signature

Step 3: Deploy the extracted service
  - The monolith now calls it over the network
  - Run both old code path and new in parallel until stable (strangler fig pattern)

Step 4: Delete the old module from the monolith once confident

The key insight: you're not rewriting anything.
You're moving clean code across a network boundary.
Dirty code cannot be cleanly extracted — it must be cleaned first anyway.

Common mistake: Trying to extract a module that was never truly separated. If your "users module" has functions scattered across 30 files in 12 directories and shares tables with billing — you're not splitting a service, you're performing surgery without a map. Clean the module in the monolith first.


🛠️ Your mission

Look at the app you are currently building (or the one from the Stage 3 capstone).

  1. Identify your modules. List the 3–5 major logical concerns your app has (e.g. auth, content, billing, notifications, analytics). Write them down.

  2. Draw your monolith. Sketch a text diagram (like the ones above) showing these modules inside a single process, sharing one database, with each module's public interface labeled.

  3. Apply the split test to each module. For each module ask: "Do I have a real, felt, current problem that extracting this module as a service would solve?" Write the answer. Almost certainly the answer is no for all of them right now — and that's the right answer.

  4. Define the boundary rules. Write three sentences describing what your modules are allowed and not allowed to do when they need data from each other. (Hint: public interface calls only, no cross-module table queries, no shared mutable global state.)

  5. Fill in the System Design Template for your app's architecture. The template has a section for "Deployment Model" — write "Modular Monolith" and justify it with at least two reasons drawn from this lesson.


✅ You're done when…

  • You have completed the System Design Template with your app's deployment model documented and justified
  • Your architecture diagram shows a monolith with clearly labeled internal modules and a single database
  • You can state — in one sentence — the specific, real scaling or team problem that would have to exist before you'd consider extracting any module as a service
  • You have written the boundary rules for your modules (what callers may and may not access)
  • You can explain to someone else why "premature microservices" is a trap without using the word "complex"

➡️ Next: Designing a System. Build It Right, Or Don't Build It At All. 🏛️

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.