Codetail

Article 10 of 15

Microservices vs Monolith

The honest trade-off guide.

20 min read

The Monolith Is Not the Problem

A monolith is a system where all components run in a single process and are deployed as a single unit. Every HTTP handler, every database query, every background job exists in the same codebase and ships together. This is not a legacy pattern. It is the correct default for most systems at most stages of their lifecycle.

The monolith's advantages compound in the early stages of a product. Developers run the entire system locally with one command. Integration tests exercise the whole request path without stubbing network calls. Refactoring crosses module boundaries freely because the compiler checks everything at once. There is no distributed systems tax: no network latency between components, no serialization overhead, no partial failure to handle.

The modular monolith

The failure mode of a monolith is not the architecture, it is the coupling. When every module imports from every other module directly, the codebase becomes a big-ball-of-mud where a change anywhere can break anything. The solution is not to split into services. It is to enforce internal boundaries.

A modular monolith enforces API contracts between modules at the code level. The Orders module cannot import directly from the Payments module's internals. It calls a published interface. Each module owns its own database schema. This discipline produces the same conceptual isolation as microservices, without the operational overhead.

Modular monolith, enforced boundaries in Python/Django

# Good: Orders calls the Payments public API
from payments.api import charge_card   # public interface

# Bad: Orders reaches into Payments internals
from payments.models import StripeCharge  # crosses boundary

# Module structure
src/
  orders/
    api.py          # public interface, only this is importable
    _internal.py    # private, other modules must not import this
    models.py
  payments/
    api.py
    _internal.py
    models.py

When a monolith genuinely struggles

!

Deploy frequency conflict

Team A deploys hotfixes five times a day. Team B owns a long-running release cycle. When they share a deploy pipeline, the slower team blocks the faster one.

!

Wildly different scaling requirements

The image processing component needs 64-core machines. The API gateway needs many small instances. Scaling the monolith means scaling both together, wastefully.

!

Different runtime requirements

One component needs Python for ML. Another needs Go for latency. A monolith forces a single runtime and language stack.

!

Blast radius of a failure is too large

A memory leak in the recommendation engine taking down the checkout flow is unacceptable. Isolation requires process boundaries.

!

Team size exceeds two-pizza rule per module

When a module is owned by 20 engineers, merge conflicts and coordination overhead dominate. The module needs to become a service with its own team.

Myths worth dispelling

Myth: Monoliths don't scale

Reality: A single well-written process can serve millions of requests per second. Stack Overflow, Shopify, and Basecamp all ran on monoliths at massive scale. Horizontal scaling (multiple instances behind a load balancer) applies equally to monoliths.

Myth: Monoliths are always big balls of mud

Reality: Coupling is a code quality problem, not an architecture problem. A monolith with strict module boundaries and enforced internal APIs is less coupled than a microservices system where services call each other synchronously in a chain.

Myth: Microservices automatically give you independent deployability

Reality: Services that share a database, or where deploying service A always requires coordinating with service B, are not independently deployable regardless of what they are called.

Myth: You need microservices to move fast

Reality: A small team moves faster in a monolith. Cross-service features in a microservices system require coordinating PRs across multiple repos, staging environments, and release schedules. That coordination cost is real.

Microservices: Independent Deployability and Its Price

A microservice is a service with a single, bounded responsibility that is independently deployable, independently scalable, and owned end-to-end by a single team. The goal is not small code. The goal is organizational independence: the team that owns the Payments service can deploy a fix at 2am without coordinating with the Orders team, the Catalog team, or the Notifications team.

The key word is independently. If deploying service A requires deploying service B first, they are not independent. If they share a database table, they are not independent. True independence requires process isolation, network boundaries between services, and each service owning its own data store. These constraints are what make microservices hard.

Architecture Evolution Explorer, e-commerce app

Single deployable unit, everything in one process

Single Process

Auth
Orders
Catalog
Payments
Notify
Single Database
Deploy unitOne build artifact, one deploy pipeline, one process
DatabaseSingle shared database, shared schema
Team couplingHigh, all teams share codebase and release together
Failure blast radiusTotal, one crash or bad deploy takes down everything
Ops complexityLow, one process to monitor, one deploy, one log stream
Dev velocityFast early, slows as codebase grows and teams collide

Bounded contexts: where to draw the lines

Domain-Driven Design's bounded context is the right unit for a microservice. A bounded context is a region of the domain model where a given set of terms has a precise, unambiguous meaning. Orders, Catalog, Payments, and Notifications are different bounded contexts because the word "product" means something different in each: a thing you sell (Catalog), a line item with a locked-in price (Orders), a charge amount (Payments), a subject line in an email (Notifications).

Orders Service

Owns: Order lifecycle, order lines, order status, fulfilment state

Emits: order.created, order.cancelled, order.shipped

Depends on: Catalog (read: product price at time of order), Payments (async: charge)

Catalog Service

Owns: Product definitions, SKUs, pricing, inventory counts

Emits: product.updated, stock.depleted, price.changed

Depends on: None upstream, source of truth for product data

Payments Service

Owns: Payment methods, charges, refunds, payment state machine

Emits: payment.succeeded, payment.failed, refund.issued

Depends on: Orders (async: consume order.created to initiate charge)

Notifications Service

Owns: Email templates, push notification routing, send logs

Emits: notification.sent, notification.bounced

Depends on: Subscribes to events from Orders, Payments, pure consumer

The distributed systems tax

Every microservice architecture incurs a set of costs that simply do not exist in a monolith. These are not implementation details you can avoid, they are structural consequences of process isolation and network communication. Before choosing microservices, you must be willing to pay each of these costs permanently.

!

Network calls instead of function calls

An in-process function call takes nanoseconds. A network call to another service takes milliseconds. A chain of five synchronous service calls adds 5ms+ of irreducible latency before a single byte of business logic runs.

!

Partial failure

A function call either returns or throws. A network call can time out, return a corrupted response, or succeed from the perspective of the server but never arrive at the client. Every service call is a distributed transaction that can partially fail.

!

Distributed tracing

A single user request now touches 5 services. When it is slow, which service is responsible? Without distributed tracing (Jaeger, Zipkin, DataDog APM), a 300ms request with no obvious cause takes hours to diagnose.

~

Service discovery

Services need to find each other. In a monolith, it is a function import. In microservices, it requires a registry (Consul, Kubernetes DNS, AWS Cloud Map) and health checking to route only to healthy instances.

!

Data consistency across services

A transaction that spans two services cannot use database ACID guarantees. Sagas and compensating transactions replace atomic commits. Eventual consistency becomes a design constraint, not an edge case.

~

API versioning at every boundary

Changing a field in a monolith is one PR. Changing a field in a service API that three other services depend on requires versioning the API, running multiple versions simultaneously, and coordinating migration across teams.

~

Operational surface area

Ten services means ten deploy pipelines, ten Kubernetes deployments, ten sets of alerts, ten log streams to correlate, and ten services to maintain TLS certificates for. The ops burden grows with service count.

Migrating: The Strangler Fig Pattern

The strangler fig is a vine that grows around a tree, gradually replacing it. Martin Fowler named the pattern after it: instead of rewriting the monolith from scratch (a project that almost universally fails), you extract services incrementally from the outside in, while the monolith continues running in production.

Each extraction is a self-contained project with a defined start and end state. The monolith shrinks one module at a time. If an extraction goes wrong, you route traffic back to the monolith. There is no big-bang cutover, no rewrite risk.

1

Identify the extraction candidate

Find a module with a clear bounded context, stable interfaces, and a team that owns it. Notification sending is a good first extraction, it is purely downstream, no other service needs to call it back synchronously.

2

Create a facade in front of the monolith module

Insert a routing layer (HTTP proxy, message consumer, or anti-corruption layer) that sits in front of the existing module. All traffic for that module now goes through the facade. No callers change yet.

3

Build the new service in parallel

The new service implements the same interface as the monolith module. It is developed and tested independently. The facade routes a percentage of traffic (or a canary subset) to the new service.

4

Migrate the data

The hardest step. The new service needs its own database. Dual-write during the transition: write to both the monolith's schema and the new service's schema. Validate consistency. Then switch reads to the new service.

5

Route all traffic to the new service

The facade now sends 100% of traffic to the new service. The monolith's module code still exists but receives no traffic. Run both in parallel for a soak period (days to weeks) to validate correctness.

6

Delete the monolith module

Once the new service is stable, delete the module from the monolith codebase. Remove the facade. The monolith no longer owns this responsibility. The extraction is complete.

The hardest part: splitting the database

Code extraction is the easy part. Database decomposition is what makes migrations genuinely hard. A shared relational database provides joins, foreign key constraints, and atomic transactions across all tables. When services get their own databases, all three disappear. Every shared table is a dependency that must be resolved before the service can be independent.

Shared tables

Symptom: Two services both write to the users table. Splitting the database means one service must call the other for user data it no longer owns directly.

Approach: Assign ownership. One service is the system of record for each table. Others read via API or event subscription. Migrate foreign key relationships to reference IDs across service boundaries.

Cross-service joins

Symptom: The reporting query joins orders, users, and products in a single SQL statement. After splitting, that query cannot exist.

Approach: Materialized views or read models. Build a dedicated read service that subscribes to events from all three and maintains a denormalized projection optimized for the query.

Distributed transactions

Symptom: Creating an order requires atomically decrementing inventory and creating a payment record. With separate databases, there is no two-phase commit.

Approach: Saga pattern: publish order.created event, inventory service decrements stock and emits stock.reserved, payment service charges and emits payment.succeeded. Compensating events handle failures.

Referential integrity

Symptom: The orders table has a foreign key to users.id. Splitting means the database can no longer enforce this constraint.

Approach: Soft references: store the user ID as a plain integer with no FK constraint. The service is responsible for data consistency. Eventual consistency is the new contract.

The Lessons of Microservices from organizations that have done it at scale (Uber, Netflix, Shopify) consistently point to the same insight: if you cannot define clear ownership boundaries in the monolith, splitting into services does not create those boundaries, it just makes the coupling more expensive. Fix the architecture in the monolith first. Extract services second.

Choosing an Architecture

The right architecture is determined by your team size, your domain maturity, and your operational capability, not by what Netflix or Google does. Netflix and Google chose microservices to solve problems that arose from thousands of engineers, years of domain knowledge, and mature platform engineering teams. Copying the solution without the context produces the costs without the benefits.

Decision matrix

SignalMonolithMicroservicesNotes
Team of 1-10 engineersbest fitavoidMicroservices overhead dominates. One engineer cannot own 8 services meaningfully. Monolith maximizes velocity at this scale.
Team of 50+ engineers across multiple squadsworkablebest fitConway's Law: system architecture mirrors communication structure. Large teams shipping independently require independent deploy units.
Early-stage product with unclear boundariesbest fitavoidService boundaries drawn too early become walls around wrong abstractions. Start monolith; let domain understanding emerge before splitting.
Components with wildly different scaling requirementsavoidbest fitImage processing needs GPU VMs; API gateway needs many small instances. Scaling the full monolith wastes resources.
Components need different language runtimesavoidbest fitML inference in Python, latency-critical API in Go, data pipeline in Scala. Service boundaries are the only way to mix runtimes.
Strict failure isolation between componentsavoidbest fitA memory leak in recommendations taking down checkout is unacceptable. Process isolation is the only real blast radius limit.
Frequent cross-cutting feature changesbest fitavoidA feature touching 5 services needs 5 PRs, 5 deploys, 5 code reviews. In a monolith it is one PR.
Strong need for local development simplicitybest fitavoidRunning 10 services locally requires Docker Compose or a dev cluster. A monolith starts with one command.
Regulated environment requiring audit trails per serviceworkablebest fitIndependent services can have independent audit logs, access controls, and compliance boundaries per domain.
Rapid iteration on a well-understood domainworkablebest fitOnce boundaries are stable and teams are aligned to services, each team ships faster independently than in a shared codebase.

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, 1968)

Conway's Law is descriptive, not prescriptive. Your system will mirror your team structure whether you intend it to or not. The inverse is also useful: if you want a microservices architecture, first restructure your teams so each team owns a domain end-to-end. Service ownership follows team ownership. Trying to implement microservices without reorganizing teams produces a distributed monolith: network calls where there used to be function calls, with none of the independence.

The distributed monolith antipattern

!

Services share a database

Direct table access across service boundaries. Any schema change requires coordinating every service that reads that table. The key benefit of microservices, data isolation, does not exist.

!

Synchronous chains of service calls

Service A calls B which calls C which calls D before returning. Latency compounds. One slow service freezes the chain. The failure modes of a monolith without the simplicity.

!

Coupled deploys

Deploying service A requires deploying service B first because of a breaking API change. These services are not independent, they are a monolith that requires two deploy commands.

!

No team owns a service end-to-end

Every service is owned by a platform team. Domain teams make changes but do not control deploys. The deployment independence that justifies microservices does not exist.

Rules of thumb

Start with a modular monolith

Enforce module boundaries in the codebase before you enforce them with network calls. If you cannot write clean module interfaces in a monolith, you cannot design clean service APIs.

Extract services when you feel the pain, not before

Deploy collisions, scaling bottlenecks, and team autonomy needs are the real signals. Architecture decisions driven by pain are better than ones driven by hype.

One team, one service

A service without a clear team owner accumulates shared ownership debt. Shared ownership means no ownership. Each service should have a named team responsible for its uptime, its API, and its data.

Use the strangler fig for migration, never a big rewrite

Big-bang rewrites from monolith to microservices fail at a very high rate. Incremental extraction keeps the monolith in production throughout and limits risk to one service at a time.

Count your services before you add another

Every service is a permanent operational commitment. Before adding a new service, ask whether the problem could be solved by a module in an existing service. The answer is often yes.