Monolith vs microservices
Article.
A monolith is one deployable artifact released as one unit. It can run as hundreds of processes across many machines; what makes it a monolith is that every change ships together. Microservices split the system into separately deployable services, each owning its data and talking over the network.
Three teams share a Django monolith and deploys are slow. Do you split it? The senior answer starts from what a network boundary costs, measured below on Python 3.14, then asks whether the problem is the architecture or the deploy pipeline.
What a boundary costs
in-process call, get_order(42) 59 ns
json.dumps of a 20-item order 25.4 us (orjson: 1.8 us)
HTTP call to localhost, keep-alive p50 889 us, p99 1,529 usTurning a function call into an HTTP call made it about 15,000 times slower before leaving the machine; across availability zones add roughly 0.5-2 ms more. Each call can now also fail, time out or be retried, and the caller needs a policy for each case.
Chains multiply it. Each service below is 99.9% available with a p50 of 10 ms and a p99 of 50 ms:
| Synchronous hops | Availability | End-to-end p99 |
|---|---|---|
| 1 | 99.90% | 49 ms |
| 3 | 99.70% | 96 ms |
| 5 | 99.50% | 135 ms |
| 8 | 99.20% | 186 ms |
Eight 99.9% services in a request path give 99.2%, about six hours of
failures a month instead of 45 minutes. And transactions stop at the
boundary: what was one atomic block becomes a saga with compensations
(Data consistency across services).
Comparison
| Dimension | Monolith | Microservices |
|---|---|---|
| Release | one artifact, together | per service |
| Cross-module call | 59 ns, cannot partially fail | ~1 ms, can fail |
| Consistency | one ACID transaction | sagas, outbox, eventual |
| Scaling | per process type, see below | per service |
| Team autonomy | shared release train | own deploys |
| Fixed cost | one pipeline, one dashboard | per service |
“A monolith can only scale as a whole” is wrong
One artifact can run as several Kubernetes Deployments: the web tier, a Celery worker Deployment per queue, the heavy report endpoint on its own Deployment behind an ingress path rule. Each gets its own replica count, autoscaling and memory limit, and a crash in one does not take down the others. That buys independent scaling and much of the blast-radius isolation without a network boundary inside the code.
What is genuinely harder in a monolith is memory per worker: every process imports everything. A 400 MB Django worker × 8 workers × 10 pods is 32 GB, most of it code the request never touches. Copy-on-write sharing helps (Python memory in production), and smaller services start faster and use less.
Why teams split: Conway’s law
Systems mirror the communication structure of the organisations that build them. The strongest reason to split is not traffic, it is deploy contention: many teams queueing for one release train. Measure it before deciding, with DORA’s lead time for changes and change failure rate, and the time changes wait in the merge queue. If those are fine, the split solves nothing.
The workable rule: one team may own several services, but no service is owned by several teams. A service that two teams must change together is the distributed monolith’s starting point.
The modular monolith, enforced
Boundaries inside one deployable cost nothing at run time, but only if they are enforced; a promise in a README erodes within months.
In the code: import-linter
[importlinter:contract:billing-public-api]
name = Only billing.api is public
type = forbidden
source_modules = shop.orders
forbidden_modules = shop.billing._ledger
allow_indirect_imports = True$ lint-imports # exit 1, CI fails
shop.orders is not allowed to import shop.billing._ledger:
- shop.orders.refunds -> shop.billing._ledger (l.1)Orders may call billing.api; reaching into billing’s internals breaks the
build (Feature-based structure).
In the database: a schema and role per module
orders_svc=> select * from billing.invoices;
ERROR: permission denied for schema billingEach module owns a schema and connects as its own role, so a cross-module
JOIN fails at once instead of quietly coupling the two. The cost: one
connection pool per role, which multiplies connections behind PgBouncer.
Splitting out a service, in order
- Boundary in the monolith first: module, schema and role, as above. Most of the work, and most of the value, is here.
- Copy the data to the new service’s database, kept current by change data capture or an outbox (The transactional outbox), never by writing to both databases from application code.
- Shadow reads: serve from the old path, also query the new one, compare answers, and fix differences.
- Cut over reads, then writes, then delete the old tables.
Dual writes from application code are the trap: when the second write fails after the first committed, the two stores disagree and nothing reconciles them.
Where it breaks in production
The distributed monolith
Services that must be deployed together, that change in the same pull request most of the time, and whose traces fan out five services deep for one request. All of the microservice costs, none of the autonomy. Measure co-change in git and trace depth; merge services that always move together.
Retry storms through synchronous chains
One slow service at the bottom of a chain; every layer above retries three times, and the load on it multiplies by 3 per layer. Retries need budgets, timeouts must shrink down the chain, and circuit breakers must stop calling a failing dependency (Timeouts, retries and backoff).
Boundaries across a hot data path
Amazon Prime Video’s stream-monitoring tool (2023) had split its pipeline into serverless components passing every video frame through S3 between steps. Moving it back into one process on ECS cut infrastructure cost by about 90%. The boundary had been drawn across the highest-volume data flow. Draw boundaries along data ownership and invariants, not along nouns.
The fixed cost of each service
Every service needs its pipeline, dashboards, alerts, on-call, at least two or three replicas for availability, and observability ingest. Segment (2018) collapsed more than 140 per-destination services back into one after the operational load, not traffic, became the bottleneck.
The trade not taken
The modular monolith is the default for most teams: enforced module boundaries, a schema per module, and separate Deployments per process type. It gives clean ownership and independent scaling of the heavy parts while keeping one transaction, 59-nanosecond calls and one release. Shopify runs one of the largest Rails codebases this way with Packwerk.
Split a module out when its team needs its own release cadence or its runtime needs genuinely differ, and do it from a boundary that already holds.
Related
Interview angle 5
- “Monolith or microservices?” - start with a modular monolith and split
when team contention or genuinely different runtime needs force it. A
network boundary turned a 59 ns call into 0.9 ms and made it fallible.
- Follow-up: “Doesn’t a monolith have to scale as a whole?” - no: one artifact can run as several Deployments with their own replicas and limits. What it cannot avoid is every process loading all the code.
- “Three teams share a monolith and deploys are slow. Do you split?” -
measure first: lead time, change failure rate, merge-queue wait. Enforced
module boundaries and a faster pipeline often fix it for far less than a
split.
- Follow-up: “And if you do split?” - boundary inside the monolith, copy data by CDC or outbox, shadow reads, cut over reads then writes; never dual writes.
- “Why did Prime Video move back to a monolith?” - the split cut across a hot data path, so every frame paid network and storage cost; one process cut cost by about 90%.
- “What is a distributed monolith?” - services that change and deploy together. Detect it with git co-change and trace depth.
- “Eight services in a chain, each 99.9% available. Overall?” - 0.999⁸, about 99.2%, and the p99 grew from 49 to 186 ms in the simulation.