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.pyWhen 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.
Single deployable unit, everything in one process
Single Process
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).
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)
Owns: Product definitions, SKUs, pricing, inventory counts
Emits: product.updated, stock.depleted, price.changed
Depends on: None upstream, source of truth for product data
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
| Signal | Monolith | Microservices | Notes |
|---|---|---|---|
| Team of 1-10 engineers | best fit | avoid | Microservices overhead dominates. One engineer cannot own 8 services meaningfully. Monolith maximizes velocity at this scale. |
| Team of 50+ engineers across multiple squads | workable | best fit | Conway's Law: system architecture mirrors communication structure. Large teams shipping independently require independent deploy units. |
| Early-stage product with unclear boundaries | best fit | avoid | Service boundaries drawn too early become walls around wrong abstractions. Start monolith; let domain understanding emerge before splitting. |
| Components with wildly different scaling requirements | avoid | best fit | Image processing needs GPU VMs; API gateway needs many small instances. Scaling the full monolith wastes resources. |
| Components need different language runtimes | avoid | best fit | ML 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 components | avoid | best fit | A memory leak in recommendations taking down checkout is unacceptable. Process isolation is the only real blast radius limit. |
| Frequent cross-cutting feature changes | best fit | avoid | A feature touching 5 services needs 5 PRs, 5 deploys, 5 code reviews. In a monolith it is one PR. |
| Strong need for local development simplicity | best fit | avoid | Running 10 services locally requires Docker Compose or a dev cluster. A monolith starts with one command. |
| Regulated environment requiring audit trails per service | workable | best fit | Independent services can have independent audit logs, access controls, and compliance boundaries per domain. |
| Rapid iteration on a well-understood domain | workable | best fit | Once 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.