SevyDevy

Guide · Backend

Monolith vs Microservices: When to Split, and When Not To

23 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Monolith vs microservices is a choice between one app you deploy (put live) as a whole and many small services, each a separate program, that you deploy separately. Most young products should start as a monolith.
  • A monolith is simpler to build, test and debug. It starts to hurt when many teams share one release, and when one bad bug can stop every feature at once.
  • Microservices let each team deploy and scale (add servers for) its part on its own. You pay with network calls that can fail, data split across databases, more systems to run and harder debugging.
  • Split a part out only when it has a clear boundary, meaning few calls in and out and its own data, plus a reason only a split fixes. A modular monolith, one app with enforced internal walls, is the usual middle step.
  • Beginners can stop after the modular monolith section. Engineers then get a test where a 0.8-second time limit let a call run 5 seconds, a script that fails the build when modules cross walls, and a migration plan.

What goes wrong when a growing team shares one app?

Releases slow down, and one bad bug can stop every feature at once. That is the real question behind monolith vs microservices. It is about whether one app still fits your team and your traffic, not whether the code is old.

Picture a common setup. A matrimony site in Lucknow lets families create profiles, search for matches, send interests, chat and pay for premium plans. The setup is made up, but the problems are real.

It began as one Python app written by three people. Today it holds 9 lakh (900,000) profiles, and 45 engineers in six teams work on it. It is still one app with one database.

Here is what went wrong in the last wedding season.

  • Release day is Thursday night. Six teams merge into one build, the release takes three hours, and about one release in five gets rolled back (undone).
  • The payments team finished a new premium plan in two days. Then it waited nine days for the next safe release.
  • On Sunday mornings, search traffic jumps to ten times the weekday level. The whole app must scale up just to serve search.
  • A bug in photo resizing used up all the memory on the web servers. For 40 minutes, nobody could log in or pay.

The head of engineering wants to split the app into 18 microservices in six months. One backend engineer asks for two weeks first, to check which of these problems a split would really fix. This guide follows that check.

What is the difference between a monolith and microservices?

A monolith is one application you build and deploy as a single unit, while microservices are many small applications deployed separately. Inside a monolith, parts call each other as functions. Microservices call each other over the network.

Five words carry the rest of this guide.

  • Monolith: every feature lives in one codebase and runs as one program, usually with one shared database. You can still run many copies of it behind a load balancer, which spreads requests across them.
  • Microservice: a separate program that owns one business job, such as search, along with its data.
  • API: the set of requests a module or service agrees to answer. Other parts must go through it.
  • Deploy: putting a new version of your code on the live servers. In a monolith, one deploy carries everyone's changes.
  • Boundary: the line between one part of a system and another. Good boundaries follow business jobs, like payments or chat, so most changes stay on one side.

James Lewis and Martin Fowler wrote a widely cited description of microservices in March 2014. They describe a suite of small services, each running in its own process (a separate running program with its own memory) and deployable on its own by automated tooling.

In their words, each service is built around a business capability. They also note that microservices prefer letting each service manage its own database.

Here is a picture for the hardest idea. Think of a joint family in one big house in Aminabad. There is one shared grocery budget, one main gate and one set of house rules. Anyone can call across the courtyard and get an answer in seconds.

That house is a monolith. The courtyard call is a function call, and the shared budget is the shared database. Sharing is cheap and fast, but every big decision needs the whole family, and one burst pipe floods every room.

The matrimony site as one app

Notice that every copy of the app holds every feature and talks to the same database. The copies always ship together.

Diagram connections:

  • Families on web and app to Load balancer
  • Load balancer to The app (many copies)
  • The app (many copies) to Main database
  • The app (many copies) to Cache
  • The app (many copies) to Payment gateway
  • Families on web and app: Search, send interests, chat, pay
  • Load balancer: Spreads requests over app copies
  • The app (many copies): Profiles, search, chat, photos, payments
  • Main database: One set of tables for every feature
  • Cache: Recent search results and sessions
  • Payment gateway: Charges for premium plans

Now picture the family splitting into separate flats across the city. Each household runs its own budget and makes its own choices. But borrowing sugar now means a phone call and a scooter ride.

That is microservices. Each team runs its part alone. Every question that used to cross the courtyard now crosses the network, and the network can be slow or down.

Where the picture breaks: a family cannot run ten identical copies of its house, but you can run many copies of one app behind a load balancer. A monolith can scale out; it just scales as a whole.

Monolith vs microservices: which one should you choose?

Choose a monolith by default, and choose microservices only for parts with a clear boundary and a problem one app cannot solve. For most small and mid-size teams, that means a well-organised monolith.

Martin Fowler wrote in June 2015 that almost all the successful microservice stories he knew began as a monolith that got too big and was broken up. Almost every system he heard of built as microservices from scratch ended up in serious trouble.

His main reason is boundaries. Services work well only when the lines between them are good and stable. On a new product you do not know where those lines go yet, and moving code between services is much harder than inside one app.

The table compares three options for the matrimony site. The middle one, the modular monolith, gets its own section later.

Three ways to structure the matrimony site
MonolithModular monolithMicroservices
What you deployOne appOne appMany services, each on its own
How parts talkAny code calls any other codeFunction calls, only through each module's public APINetwork calls through each service's API
DataOne shared databaseOne database, each table owned by one moduleEach service owns its own data
A memory bug in photo resizingCrashes every process that runs resizing, often all web serversSame as a monolithCrashes only the photo service's processes
Scaling search aloneOnly by adding copies of the whole appOnly by adding copies of the whole appAdd search servers only
Debugging one slow requestOne set of logs to readOne set of logs to readTracing across several services
Teams waiting on each other's releasesOftenStill one release, but fewer clashesRarely
Extra work to run itLowestLow, plus a boundary check in the buildHighest: a pipeline, dashboards and an on-call rota (who gets woken when it breaks) per service

What problems do microservices actually solve?

Microservices mainly solve team and operations problems, not code problems. They let teams deploy on their own, scale busy parts on their own, and keep some failures from spreading.

  • Independent deploys. The payments team ships its premium plan the day it is ready, without waiting for chat and search.
  • Independent scaling. Search can run on 30 servers on Sunday morning while chat runs on 4.
  • Fault isolation. If photo resizing eats all its memory, only the photo service falls over. Logins and payments keep working.
  • The right tool per job. A search service can use a search engine built for matching, without forcing it on every other team.

There is also a people reason. In 1968, Melvin Conway observed that a system's design tends to copy the communication structure of the organisation that builds it. This is now called Conway's law.

Six teams sharing one codebase end up sharing one release calendar too. Separate services let the code follow the teams.

Check the cheaper fix first

Some of these benefits do not need a split. One codebase can run as several types of process, such as web servers for requests and worker processes for slow background jobs.

The Twelve-Factor App guide calls this assigning each type of work to a process type. Each pool is sized on its own, but every pool still ships in the same release.

A worker that runs out of memory takes down its own process, not the web servers. That holds if pools run on separate machines or have their own memory limits.

The engineer's two-week check found three cheaper fixes. The release took three hours mainly because tests took 70 minutes and the checklist was manual. Faster tests and an automated deploy fixed most of that.

Daily deploys ended the nine-day wait for payments. Moving photo resizing into a separate worker pool stopped the login outage. Only the Sunday search spike still pointed at a split.

What are the disadvantages of microservices?

The main disadvantages are network calls that can fail, data split across services, many more things to run, and harder debugging. Martin Fowler calls this extra cost and risk the microservice premium.

A function call becomes a network call

Inside the monolith, finding matches is a function call. The call itself costs well under a microsecond and never gets lost; the search work inside it is the same either way. Across services, the same request can be slow, time out or fail halfway.

You can see the gap yourself. This command times a tiny Python function call.

bash
python3 -m timeit -s "def f(x): return x" "f(42)"

It prints a line like 2000000 loops, best of 5: 175 nsec per loop, which is nanoseconds per call. In our sandbox, an HTTP call to a service on the same machine took 1 to 5 milliseconds, thousands of times longer. A real network adds more.

One database becomes many

In the monolith, a premium payment and the premium badge on the profile are saved in one database transaction. A transaction is a group of changes that either all happen or none do.

Split payments and profiles into services with their own databases, and that promise is gone. The payment can succeed while the badge update fails. You now need a pattern like a saga, a chain of steps where each step has an undo.

More to run and more to watch

Each service needs its own build pipeline (the automated steps that test and ship code), dashboards, alerts and an on-call owner, the person woken up when it breaks.

To follow one slow request, you need tracing. Tracing tags each request with an id and records its path through every service.

Segment, a customer data company, wrote about this cost in July 2018. One part of its system had grown to over 140 services. Three full-time engineers spent most of their time just keeping it alive.

When should you split a monolith into microservices?

Split a part out when it has a clear boundary and a reason that only a split fixes. Strong reasons are a team that must ship alone, a very different load or technology, or failures that separate process pools cannot contain.

The engineer turns this into a two-question test for every part of the site. Follow the diagram from the top.

The two-question test for each part

A part becomes a service only if both answers are yes. At the matrimony site, profiles, chat and payments stop at the first question, and photos at the second.

Diagram connections:

  • One part of the app to Clear, stable boundary?
  • Clear, stable boundary? to Keep it as a module (No)
  • Clear, stable boundary? to A reason only a split fixes? (Yes)
  • A reason only a split fixes? to Keep it as a module (No)
  • A reason only a split fixes? to Extract a service (Yes)
  • One part of the app: Search, chat, photos, payments, profiles
  • Clear, stable boundary?: Few calls in and out, little shared data
  • A reason only a split fixes?: Own team, own load or tech, or crashes pools can't isolate
  • Keep it as a module: Tidy its boundary inside the app
  • Extract a service: One at a time, behind a router

Here is what the test says for each part.

  • Photo processing: a clear boundary, since an image goes in and resized images come out. The worker pool already contains its crashes, so the test alone says keep it. The team splits it anyway, as a low-risk warm-up before search, to learn to run a service.
  • Search: a clear boundary, a Sunday load ten times the weekday level, and a search index the rest of the app does not need. Split it second.
  • Payments: the premium badge must change in the same transaction, so the boundary is not clean yet. Keep it inside for now.
  • Profiles, interests and chat: they read each other's data on almost every request. Keep them together.

The warm-up idea comes from Zhamak Dehghani's April 2018 article on Fowler's site. She advises starting with a capability that is fairly decoupled from the monolith, to build skills before touching core parts.

So the answer is two services, not 18: search, which the test demands, and photos as the warm-up. Some signs say you should not split anything yet.

  • One or two small teams still ship comfortably from one codebase.
  • Nobody can say clearly where one part ends and the next begins.
  • You have no automated deploys, monitoring or tracing yet.
  • The real pain is slow tests or a manual release checklist.

The third sign matters most. Fowler's Microservice Premium names automated deployment, monitoring, dealing with failure and eventual consistency, meaning data that catches up after a delay. A team that cannot do these for one app will struggle with ten.

What is a modular monolith?

A modular monolith is one app, deployed as one unit, with enforced boundaries between its parts. Each module owns its own tables and offers a small public API that other modules must use.

Go back to the joint family. Before anyone moves out, each household agrees to keep its own account book and cupboard. They still share one roof and one gate, but nobody takes from another household's cupboard without asking.

Shopify took this road. In February 2019, its engineers described their main app as one of the largest Ruby on Rails codebases in existence. Rails is a web framework, and more than a thousand developers had worked on that app.

Instead of moving to microservices, they reorganised the code into components such as orders, shipping, inventory and billing. Each component got a public API and exclusive ownership of its data.

They also built a tool called Wedge. It hooks into their automated build to record calls between components, then lists the ones that skip a component's public API.

A module that only talks through its API is also the cheapest thing to turn into a service later. You swap those function calls for network calls, and nothing else has to move.

How do you make a call between services fail safely?

Give every call a time limit and a fallback answer, then check what the time limit really covers. Here is the matches page after search becomes its own service.

python
import os
import requests

SEARCH_URL = os.environ["SEARCH_SERVICE_URL"]
last_good = {}  # profile id -> the last list search sent back

def find_matches(profile_id):
    # Before the split this was one line: return search.find_matches(profile_id)
    try:
        resp = requests.get(f"{SEARCH_URL}/matches",
                            params={"profile": profile_id}, timeout=0.8)
        resp.raise_for_status()
        matches = resp.json()["matches"]
        last_good[profile_id] = matches
        return matches
    except requests.RequestException:
        return last_good.get(profile_id, [])  # an older list beats an error page

What just happened? The timeout stops a service that hangs before replying from freezing the page. The except branch decides what families see when search fails: the last list search sent back, or an empty list. The monolith never needed that choice.

Pause and predict: with timeout=0.8, what is the longest this call can take? Keep your guess in mind as you read the table.

We ran this exact function with requests 2.34.2 against small fake search services on one machine. The table shows what came back and how long it took.

find_matches with timeout=0.8 against fake search services
Fake search serviceWhat came backTime taken
HealthyThe matchesUnder 0.01 s
Waits 2 s before sending anythingEmpty list (nothing saved yet)0.80 s
Sends one byte every 0.5 sThe matches5.01 s
Returns error 500Last saved list, or emptyUnder 0.01 s
Not runningLast saved list, or emptyUnder 0.01 s

Look at the third row. With one number, requests uses it as the limit to connect and as the limit for each wait between bytes. As of 19 October 2026, its documentation says the timeout is not a limit on the whole download.

For a hard cap on the whole call, wrap it in a deadline. In our test, asyncio.timeout(1.0), available from Python 3.11, around an httpx 0.28.1 call stopped the same trickle after just over 1 second in each run. The httpx timeout of 0.8 alone let it run over 5 seconds, like requests. The deadline needs async code, and it raises TimeoutError, so catch that next to the client's errors or the fallback never runs.

Real systems add more. They retry with growing waits, called backoff, and only for requests that are safe to repeat. A circuit breaker stops calling a service that keeps failing. Each is code your team now owns and must test.

How do you stop modules reaching into each other's code?

Run a check in every build that fails when one module imports another module's private code. Each module, such as app/payments, keeps its public functions in one file, api.py.

python
import ast
import pathlib
import sys

ROOT = pathlib.Path("app")  # one folder per module: app/payments, app/search ...
problems = []

def allowed(owner, target):
    parts = target.split(".")  # "app.payments.models" -> ["app", "payments", "models"]
    if parts[0] != "app" or len(parts) < 2 or parts[1] == owner:
        return True  # not our code, or the file's own module
    return len(parts) > 2 and parts[2] == "api"  # other modules: only through api

for path in sorted(ROOT.rglob("*.py")):
    owner = path.relative_to(ROOT).parts[0]  # the module this file is in
    for node in ast.walk(ast.parse(path.read_text())):
        if isinstance(node, ast.Import):
            targets = [a.name for a in node.names]
        elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
            targets = [f"{node.module}.{a.name}" for a in node.names]
        else:
            continue  # relative imports are not checked here
        for target in targets:
            if not allowed(owner, target):
                problems.append(f"{path}:{node.lineno} imports {target}")

print("\n".join(problems) or "All boundaries respected")
sys.exit(1 if problems else 0)

The key function is allowed. A file may import anything from its own module, but code in another module only through that module's api. The script checks both import forms, so import app.search.index is caught too.

To check it, run it from the folder that holds app. On our test tree with three bad imports, it printed three lines and exited with code 1. The first line was app/profiles/views.py:2 imports app.payments.models.Payment.

After the fix it printed All boundaries respected and exited with code 0. A simpler version that checked only the module name in from-import lines caught just one of the three.

Know its limits. It reads code without running it, so imports built at run time through importlib slip past. It skips relative imports, and it treats anything under a module's api as public. It also flags importing another module's package itself, such as from app import search, so import its api instead. Files directly under app, such as main.py, count as modules of their own.

How do you migrate from a monolith to microservices without a rewrite?

Use the strangler fig pattern: build the new service beside the monolith and move traffic to it one route at a time. You delete the old code only when nothing uses it anymore.

Martin Fowler named it after strangler figs he saw on a 2001 holiday in the rainforests of Queensland, Australia. The fig grows on a host tree, drawing nutrients from it, until it reaches the ground and grows roots of its own.

His page, last updated in August 2024, says plans to replace a serious system in one go fail most of the time. Rewrites take long, and much of the old behaviour they copy is not really wanted.

Photos moved out first, as the warm-up. The diagram shows search, the harder second move.

Moving search out with a strangler fig

Notice the router: it decides per route whether the old app or the new service answers. Search learns about profile changes from events, never from the main database.

Diagram connections:

  • Families on web and app to Router
  • Router to The old app (All other routes)
  • Router to Search service (Match searches)
  • The old app to Main database
  • The old app to Profile change events (Profile changed)
  • Profile change events to Search service (Update the index)
  • Search service to Search index
  • Families on web and app: Same screens as before
  • Router: Sends each route to old or new code
  • The old app: Everything except search and photos
  • Main database: Profiles, chat, payments
  • Profile change events: Holds each change until search reads it
  • Search service: Finds matches, scales up on Sundays
  • Search index: Only the profile fields search needs

An event is a short message saying something happened, such as profile 4123 changing its city. A message queue holds these events until the search service reads them, even if search was down for a while.

Search uses the events to update its index, a copy of profile data arranged for fast lookups.

  1. Put a router in front

    Every request already passes through the load balancer. Add routing rules there, so each route can go to the old app or to a new service.

  2. Give search its own copy of the data

    The app publishes an event whenever a profile changes. The search service reads these events and keeps its own index of the fields it needs.

  3. Run both and compare

    For two weeks, both the app and the new service answer every search. Families see only the old answer, and a script logs every difference.

  4. Move traffic in small steps

    Send 5 percent of searches to the new service, then 25, then all of them. Rolling back means changing one router rule.

  5. Delete the old code

    Once the new service has handled all searches for a month, remove search from the app. Until then, both versions are temporary code you must maintain.

Six months later, the site runs one modular app and two services. Releases take 40 minutes after the test speed-up. The photo service has crashed twice, and no family noticed.

Can you move back from microservices to a monolith?

Yes, and some teams do when services multiply faster than the reasons for them. Merging services back can cut deploy work and on-call load, but you give up some fault isolation.

Segment's July 2018 post describes exactly this. It had a separate service and queue for each destination, an outside tool such as an analytics service that receives customer data.

It moved all destination code into one repository and one service, and replaced the per-destination queues with a new system called Centrifuge.

Before, a change to a shared library meant deploying more than 140 services. After, one engineer could deploy the service in minutes. A large shared pool of workers absorbed load spikes, so quiet destinations stopped paging the team.

They were honest about the price. A bug in one destination could now crash the service for all destinations. In-memory caching worked less well, and updating one shared dependency could break several destinations.

The lesson is not that microservices are wrong. Each service must keep earning its cost, so review them now and then, and merge the ones that no longer pay their way.

How do you explain monolith vs microservices in a system design interview?

Start with one app, then split only the parts the requirements force apart, giving a reason for each. Interviewers want to hear the trade-off, not a fixed favourite.

  1. Start with one app

    Draw one app with a database behind a load balancer. Say why it is a good start: one deploy, one set of logs and simple transactions.

  2. Ask about the pressures

    Ask how many teams there are, how traffic changes through the week, and which failures must never spread. These answers, not code size, decide the split.

  3. Try the cheap fixes first

    Mention separate process pools for heavy or slow work, and enforced modules inside the app. Say which problems they already solve.

  4. Extract one or two parts

    Choose parts with clear boundaries and a reason only a split fixes, and say the reason out loud for each one.

  5. Say who owns which data

    Give each service its own data. Explain how other parts get copies, through events or API calls, never through shared tables.

  6. Handle failure between services

    Add timeouts, a deadline for the whole call, retries with backoff for safe requests, and a fallback answer. Mention a saga for any flow that changes data in two services.

  7. Describe the migration

    Put a router in front and use the strangler fig pattern. Move traffic in small steps that you can roll back with one change.

Questions people ask

Are microservices better than a monolith?
No, neither is better in general. Microservices suit large systems with many teams, and parts that need separate scaling or failure isolation. For a small team or a new product, a monolith is usually faster to build and cheaper to run. You can still split it later.
How many microservices should a startup have?
Usually none at first, or very few. Start with one well-structured app, and extract a service only when a part has a clear boundary and a reason only a split fixes. One app plus a few services can carry a product a long way.
Should each microservice have its own database?
Yes, in most designs each service owns its data, and others reach it only through the service's API. Sharing tables ties releases together, because one table change can break another service. Separate data ownership is what makes independent deploys possible.
What is a distributed monolith?
A distributed monolith is a set of services that cannot be changed or deployed on their own. Common signs are shared database tables, services that must release together, and long chains of calls for every request. It carries the costs of microservices without their main benefit.
Do you need Kubernetes to run microservices?
No. Kubernetes is an open-source platform that runs containers, which are apps packed with everything they need, across many machines. It helps most when you have many services. A few services can run on plain virtual machines or a simpler managed container service.
How big should a microservice be?
Size a service by business job, not by lines of code. A good service owns one capability, such as search or payments, and can change without asking other teams. If two services must always change together, they probably belong in one.
Can a monolith scale?
Yes. You can run many copies of a monolith behind a load balancer, and run separate pools for web requests and background work. What it cannot do is scale one feature's code alone, so a busy feature means more copies of everything.

Sources and further reading

Practise this with Lala

Practise