Skip to content
Blog

Monolith vs Microservices vs Modular Monolith, Explained

By Published A short read

Monolith vs microservices, and why most new apps should start as a modular monolith: one app split into orders, billing, users and stock modules, each with its own data, talking only through small public doors

A monolith is one app that you build and deploy as a single unit. Microservices split an app into many small services that talk over the network, each with its own database. A modular monolith sits in between: one app, split inside into modules with strict borders. For most new teams, it's the best place to start.

In this post we start with three kitchens: one where everyone cooks everything, a row of separate food stalls, and one kitchen with clear stations. Then we meet the real things: a monolith, microservices and a modular monolith. We look at what goes wrong in each, what microservices really cost, and when one part of an app has earned the right to become its own service. Then we build a tiny modular monolith in Python, with a test that fails the moment one module reaches into another's private code.

A few words first. To deploy an app means to put a new version of it live, where users can reach it. A module is one part of a program kept in its own file or folder, like one chapter of a book. A service is a program that runs on its own and answers requests from other programs. A database is where an app keeps its data so it survives a restart, like a shop's account book.

Key takeaways

  • A monolith is one app, built and deployed as one unit. That's not an insult: it only describes how it ships.
  • A monolith goes bad when every part reaches into every other part. That mess has a name: a big ball of mud.
  • Microservices give each business area its own small app and its own database. You pay for it with network calls that fail, many separate deploys, data split across databases and harder debugging.
  • A modular monolith keeps one app but gives each business area its own module with a small public face. Modules talk only through public functions or events.
  • A boundary only holds if something checks it. Our 40-line Python check fails the build when one module uses another's private parts.
  • Split a module into a service only when it has a real reason: it needs to scale on its own, a separate team needs to release it on its own, or it needs different technology. And its borders should have stopped changing.

Monolith vs microservices vs modular monolith: what's the difference?

The short version: a monolith is one app with no walls inside, microservices are many apps with the network between them, and a modular monolith is one app with walls and doors inside.

Big ball of mud (a monolith without walls)MicroservicesModular monolith
What it isOne app, parts mixed togetherMany small apps, one per business areaOne app, one module per business area
Everyday pictureOne big kitchen, everyone cooks everythingA row of separate food stallsOne kitchen with clear stations
How parts talkAny code calls any codeNetwork calls (often HTTP) and messagesPublic functions and in-process events
When a call failsRarely: it's a function callOften: the other service may be down or slowRarely: it's a function call
DeploysOne, for every changeOne per service, each on its ownOne, for every change
DataOne database, any code reads any tableOne database per serviceOne database, each module owns its own tables
DebuggingOne log, one programOne request crosses many programs and logsOne log, one program
Good forNothing: it's what a monolith becomes without wallsBig systems, many teams, parts that scale differentlyMost new apps, and most teams

A plain monolith sits in the first column only when nobody keeps its parts apart. Built carefully, the same single app is a modular monolith. So the real choice for a new app is usually between the last two columns.

What are a monolith and microservices like in real life?

Think of three ways to run a food business.

  • One big kitchen where everyone cooks everything: the monolith gone wrong. There's one fridge and one spice rack, and any cook can grab anything. It's quick at first. But the dosa cook starts using the biryani cook's onions, someone changes the masala recipe for one dish and three other dishes taste different, and in the rush nobody knows who is cooking what.
  • A row of separate food stalls: microservices. Each stall has its own counter, its own fridge and its own cook, and can change its menu or close for repairs without asking the others. But if the chaat stall needs chutney from the next stall, someone has to walk over, and the other stall might be busy or shut. A customer who orders from three stalls gets three bills, and the stalls must agree on who paid for what. Every stall also needs its own licence, gas and cleaning.
  • One kitchen with clear stations: the modular monolith. There's a tandoor station, a curry station and a dessert station, each with its own shelf. Cooks don't take from another station's shelf. They ask through the pass, the window where orders and dishes are handed over, or pin a ticket on the rail for the station to pick up. It's still one kitchen with one gas line and one licence, so a question to the next station takes seconds, not a walk. And if the dessert station ever gets so busy that it needs its own shop, it's easy to move out, because it already works on its own.

In software, the shelf is a module's data, the pass is its public functions, and the ticket rail is events. We'll build all three in Python below.

Three shapes of an app: a big ball of mud where orders, billing, users and stock all reach into each other's code and one shared set of tables; microservices where the same four areas are separate apps, each with its own database, joined by network calls that can fail; and a modular monolith where the four areas are modules inside one app, each with its own tables, talking only through public functions and events
The same four business areas, three ways. In the mud, every part touches every other part and one shared set of tables. In microservices, every line is a network call that can fail. In the modular monolith, every arrow is a plain function call or event, through a module's public door.

What is a monolithic architecture?

Martin Fowler, a software author who has written about this since the word "microservices" first caught on, and his colleague James Lewis describe the monolithic style as "a monolithic application built as a single unit." All the code for every feature lives in one program. You build it once, test it once and deploy it once. If it gets busy, you run several copies of the whole thing behind a load balancer, the traffic-sharing tool from our previous post.

That has real strengths. Calling another part of the app is a normal function call: fast, and it doesn't fail because a network cable did. All the data sits in one database, so you can change an order and its bill together in one transaction, a group of changes the database saves all together or not at all. When something breaks, there's one program and one log to look in.

The trouble starts when the app gets big. Lewis and Fowler list the usual complaints: "Change cycles are tied together - a change made to a small part of the application, requires the entire monolith to be rebuilt and deployed." And: "Scaling requires scaling of the entire application rather than parts of it that require greater resource." In plain words: fix one spelling mistake on the invoice page and you still ship the whole app, and if only the search feature is busy, you still have to run more copies of everything.

But "monolith" doesn't have to mean "messy". In his article on microservice trade-offs, Fowler writes: "there's no reason why a monolithic system shouldn't have a good modular structure." The mess comes from something else.

What is a big ball of mud?

In 1997, Brian Foote and Joseph Yoder wrote a paper about the shape most big programs really end up in. They called it the big ball of mud: "A BIG BALL OF MUD is haphazardly structured, sprawling, sloppy, duct-tape and bailing wire, spaghetti code jungle." Spaghetti code is code where every part is tangled with every other part, so you can't pull one strand without moving the whole plate.

Their paper also says how it happens: "Information is shared promiscuously among distant elements of the system, often to the point where nearly all the important information becomes global or duplicated." Global means any code anywhere can read and change it. Nobody plans this. It grows one shortcut at a time: the orders code needs a customer's balance, so it reads the billing table directly, just this once. Fowler explains why it's so easy: "The trouble is that, with a monolithic system, it's usually pretty easy to sneak around the barrier."

Shopify, the online store platform, hit exactly this. Its engineering blog says the main app had been "worked on for over a decade by more than a thousand developers", and by 2016: "The application was extremely fragile with new code having unexpected repercussions." Their example: if the shipping code called straight into the tax code, a change to how tax was worked out could change shipping prices too. That's the masala recipe changing three other dishes.

Several teams reached for microservices to escape the mud. Fowler: "Indeed this frustration with the common fate of monoliths is what's driven several teams to microservices."

What are microservices?

Lewis and Fowler's 2014 article defines the style as "an approach to developing a single application as a suite of small services, each running in its own process and communicating with lightweight mechanisms, often an HTTP resource API." An API is the set of requests or functions one program offers to others. A process is a running program with its own memory. HTTP is the way web browsers and servers talk, so each service calls the others the way a browser calls a website. They add: "These services are built around business capabilities and independently deployable by fully automated deployment machinery." A business capability is one thing the business does, such as taking orders or billing customers.

Microsoft's architecture guide puts it in one line: "Microservices are small, independent, and loosely coupled components that a single small team of developers can write and maintain." Loosely coupled means each part depends on the others as little as possible, like stalls that can open and close on their own.

What you get in return:

  • Deploy one part on its own. Change billing, ship billing. Microsoft: "teams can update existing services without rebuilding or redeploying the entire application."
  • Scale one part on its own. If only search is busy, run ten copies of search and one of everything else.
  • Firm walls. One service can't read another's variables, because they're in different programs. Fowler: "Putting the modules into separate services makes the boundaries firmer, making it much harder to find these cancerous workarounds."
  • Mix technologies. "With microservices you can mix multiple languages, development frameworks and data-storage technologies." One service in Python, another in the Go language.

Our previous post, What is an API gateway?, shows the front door that usually sits in front of a set of microservices.

What do microservices cost?

Fowler calls the extra cost the microservice premium: "microservices introduce complexity on their own account. This adds a premium to a project's cost and risk - one that often gets projects into serious trouble." Here's what that premium is made of.

1. Network calls that fail. Inside one program, a function call just works. Between services, it's a message over the network, and Lewis and Fowler are blunt: "Any service call could fail due to unavailability of the supplier, the client has to respond to this as gracefully as possible." The supplier here is the service being called. Every call now needs a plan for "what if it's down?" and "what if it's slow?". Calls are slower too: "Remote calls are more expensive than in-process calls". And the waits add up along a chain. Microsoft warns that "if the chain of service dependencies gets too long (service A calls B, which calls C...), the extra latency can become a problem." Latency is the waiting time before an answer comes back.

2. Many separate deploys. Ten services means ten build pipelines (the automatic steps that build, test and ship code), ten things to watch and ten things to update safely. Fowler: "You need a mature operations team to manage lots of services, which are being redeployed regularly." And each service must keep its promises to the others while they change at different times. Microsoft: "Updates to a service must not break services that depend on it."

3. Data split across databases. Microsoft's rule for microservices: "Two services shouldn't share a data store. Each service manages its own private data store, and other services can't access it directly." That keeps services apart, but you lose something big. In a monolith, Fowler notes, "you can update a bunch of things together in a single transaction." Across services, you can't. Placing an order and charging for it become two saves in two databases, and for a moment one can be done while the other isn't. Fowler: "Maintaining strong consistency is extremely difficult for a distributed system, which means everyone has to manage eventual consistency." Eventual consistency means the data will agree in the end, but not at every moment. It's like the three stalls settling who paid for what after the customer has left.

4. Harder debugging. One click from a user can pass through five services, each writing its own log on its own machine. To follow it, you need to tie those log lines together. Microsoft: "Correlated logging across services can be challenging. Typically, logging must correlate multiple service calls for a single user operation." Many teams also make calls asynchronous (send a request and carry on without waiting) to keep things fast, and Fowler warns: "Asynchronous programming is hard: hard to get right, and much harder to debug."

None of this means microservices are bad. Teams run them on purpose, and in every size: Fowler has seen systems "vary from a team of 60 with 20 services to a team of 4 with 200 services." It means you should buy them only when you need what they give. Fowler's guideline: "don't even consider microservices unless you have a system that's too complex to manage as a monolith".

What is a modular monolith?

Shopify's engineering blog gives a clear definition: "A modular monolith is a system where all of the code powers a single application and there are strictly enforced boundaries between different domains." A domain here is a business area, like orders or billing. It's one app with one deploy, but inside it's split the way microservices would split it.

It's also what Fowler suggests for most software: "The majority of software systems should be built as a single monolithic application. Do pay attention to good modularity within that monolith, but don't try to separate it into separate services." And in his trade-offs article: "It's important to stress that it's perfectly possible to have firm module boundaries with a monolith, but it requires discipline." A modular monolith is that discipline, written down as rules and checked by a program.

Five rules make it work:

  1. One module per business area. Make folders like orders/, billing/ and users/, not controllers/, models/ and helpers/. Each module holds everything for its area. That's the same way Lewis and Fowler say microservices should be cut: "splitting up into services organized around business capability". Microsoft says the same: "Model services around the business domain."
  2. A small public face, private insides. Each module shows other modules a few functions and events, and hides everything else. Spring Modulith, a toolkit that brings this style to Java, describes a module as "An API exposed to other modules" plus "Internal implementation components that are not supposed to be accessed by other modules." In Python, a leading underscore marks the private parts. Python's style guide, PEP 8, says internal names "should still be prefixed with a single leading underscore."
  3. Talk only through the public face. To get something from billing, call one of billing's public functions. To tell other modules that something happened, publish an event, a short announcement like "order 7 was placed" that any module can listen for. The module that announces doesn't need to know who's listening, like the ticket rail in the kitchen.
  4. Each module owns its data. Billing's tables belong to billing. Orders never reads them directly; it asks billing. Shopify: "Each component defined a clean dedicated interface with domain boundaries expressed through a public API and took exclusive ownership of its associated data."
  5. A program checks the borders. Rules in a document get broken on a busy Friday. Shopify built a tool called Wedge that "highlights any violations of domain boundaries (when another component is accessed through anything but its publicly defined API), and data coupling across boundaries." Spring Modulith has a verify() step where "all references to types that reside in application module internal packages are rejected". We'll write our own in about 40 lines.

Notice what you keep from the monolith: function calls that don't fail over a network, one database where a transaction still works, one deploy and one log. And what you keep from microservices: clear borders, one owner per area, and modules that are ready to move out later.

Our Python example: the orders module calls billing.can_pay, a public function, and publishes an order_placed event that billing listens for. A crossed-out red arrow shows orders reaching into billing's private _wallets data, which check_boundaries.py rejects
Our example in one picture. Orders talks to billing through two doors: a public function and an event. Reaching for billing's private data fails the check.

How do you build a modular monolith in Python?

Let's build a tiny shop with two modules: orders takes orders, and billing keeps each customer's wallet balance and the invoices. It's plain Python, standard library only (the tools that come with Python), so there's nothing to install. Make a folder with these files:

shop/
  events.py            the ticket rail: a tiny event bus
  billing/
    __init__.py        billing's public face
    _wallets.py        billing's private data
  orders/
    __init__.py        orders' public face
  main.py              runs the shop
  check_boundaries.py  fails if a module breaks the rules

In Python, a folder with an __init__.py file is normally a package, a module made of several files. The docs: "The __init__.py files are required to make Python treat directories containing the file as packages". Whatever billing/__init__.py defines is what import billing gives you, so it's billing's public face. The first line of each file below is a comment with its path.

First, the ticket rail. An event bus keeps a list of who wants to hear about which event and calls them when it happens. Ours is in-process: everything stays inside the one running program, with no network.

# events.py
"""A tiny in-process event bus: who wants to hear about what."""
_listeners = {}


def subscribe(event, handler):
    _listeners.setdefault(event, []).append(handler)


def publish(event, **data):
    for handler in _listeners.get(event, []):
        handler(**data)

subscribe adds a function to the list for an event name. publish calls every function on that list. **data collects named values such as customer="asha" and passes them on.

Next, billing's private data. The underscore in _wallets.py says "billing's shelf: hands off". Here it's two plain Python values. In a real app, these would be billing's own tables in the shared database.

# billing/_wallets.py
"""Billing's own data. Only code inside billing/ may touch it."""
BALANCES = {"asha": 500, "ravi": 100}   # rupees
INVOICES = []

Billing's public face. It offers two public functions, and it listens for one event:

# billing/__init__.py
"""Billing's public face. Other modules use only these names."""
import events
from . import _wallets


def can_pay(customer, amount):
    return _wallets.BALANCES.get(customer, 0) >= amount


def invoices(customer):
    return [i for i in _wallets.INVOICES if i["customer"] == customer]


def _charge(order_id, customer, amount):   # private: runs on an event
    _wallets.BALANCES[customer] -= amount
    _wallets.INVOICES.append(
        {"order": order_id, "customer": customer, "amount": amount})


events.subscribe("order_placed", _charge)
  • from . import _wallets means "from my own package". Billing is allowed to open its own shelf.
  • can_pay and invoices are the pass: the questions other modules may ask. .get(customer, 0) gives 0 for a customer billing has never heard of.
  • _charge starts with an underscore, so nobody outside billing should call it. Billing hands it to the event bus instead: "when an order is placed, tell me".

Now orders. It uses both doors: it asks billing a question with a public function, then announces the new order with an event.

# orders/__init__.py
"""Orders' public face: place_order is the only door in."""
import billing
import events

_orders = {}


def place_order(customer, item, amount):
    if not billing.can_pay(customer, amount):     # 1. a public function
        return None
    order_id = len(_orders) + 1
    _orders[order_id] = {"customer": customer, "item": item}
    events.publish("order_placed", order_id=order_id,   # 2. an event
                   customer=customer, amount=amount)
    return order_id

Look at what orders doesn't know. It doesn't know how billing stores balances, and it doesn't know that billing charges the wallet when an order is placed. If tomorrow a shipping module wants to hear about new orders too, it subscribes to "order_placed", and orders doesn't change at all.

Finally, main.py plays a day at the shop:

# main.py
import billing
import orders

print(orders.place_order("asha", "headphones", 300))  # 1
print(orders.place_order("ravi", "keyboard", 800))    # None
print(orders.place_order("asha", "mouse", 150))       # 2
print(billing.can_pay("asha", 100))                   # False
for invoice in billing.invoices("asha"):
    print(invoice)

Run python main.py from inside the shop folder and you get:

1
None
2
False
{'order': 1, 'customer': 'asha', 'amount': 300}
{'order': 2, 'customer': 'asha', 'amount': 150}

Read it line by line. Asha has 500 rupees, so her 300-rupee headphones go through as order 1, and billing hears the event and charges her. Ravi has 100, so his 800-rupee keyboard is turned down and no order is made. Asha's mouse is order 2, which leaves her 500 - 300 - 150 = 50 rupees, so she can't pay 100 any more. Her two invoices exist, though orders never wrote one: billing did, when it heard each event.

How do you stop one module reaching into another?

Python doesn't lock doors. Anyone can write from billing._wallets import BALANCES and it will work. PEP 8 calls the leading underscore a weak "internal use" indicator: a signal, not a lock. So we add a guard: a small program that reads every file and fails if a module touches another module's private parts. Run it on every push, in your tests or CI (the checks that run automatically on each change), and the border holds.

# check_boundaries.py
"""Fails if one module reaches into another module's private parts."""
import ast
import sys
from pathlib import Path

MODULES = {"orders", "billing"}


def problems(root=Path(".")):
    files = list(root.glob("*.py"))
    for module in MODULES:
        files += (root / module).rglob("*.py")
    found = []
    for path in files:
        owner = path.relative_to(root).parts[0]   # "orders", "main.py"...
        for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
            if isinstance(node, ast.Import):
                names = [alias.name for alias in node.names]
            elif isinstance(node, ast.ImportFrom) and node.module:
                names = [f"{node.module}.{a.name}" for a in node.names]
            elif isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
                names = [f"{node.value.id}.{node.attr}"]
            else:
                continue
            for name in names:
                first, *rest = name.split(".")
                if first in MODULES and first != owner and any(
                        part.startswith("_") for part in rest):
                    where = path.relative_to(root).as_posix()
                    found.append(f"{where}:{node.lineno} uses {name}")
    return found


if __name__ == "__main__":
    found = problems()
    print("\n".join(found) or "boundaries ok")
    sys.exit(1 if found else 0)
  • ast.parse reads a Python file without running it and turns it into a tree of pieces: "Parse the source into an AST node." AST stands for abstract syntax tree, the program's grammar laid out like a family tree.
  • ast.walk visits every piece of that tree. We look at three kinds: import x, from x import y, and x.y (an attribute, such as billing._charge).
  • For each name, first is the module it points into. If that's another module, and any later part starts with an underscore, it's a private part. That's a problem.
  • The owner is the folder the file sits in, so billing may still use its own _wallets. main.py is checked too: it's the owner of nothing.
  • sys.exit(1) ends the program with exit code 1, which every test runner and CI system reads as "failed".

Run python check_boundaries.py on our shop and it prints:

boundaries ok

Now let's break it on purpose. A teammate in a hurry wants to add a 10-rupee delivery fee, and instead of asking billing, takes it straight from billing's shelf. They add one line to orders/__init__.py just under import events:

from billing._wallets import BALANCES   # reaching into billing's shelf

and one just before return order_id:

    BALANCES[customer] -= 10   # a sneaky delivery fee

It would even work. But the check catches it, names the file and line, and exits with 1:

orders/__init__.py:5 uses billing._wallets.BALANCES

That's the first strand of spaghetti, stopped before it's merged. The right fix is to ask billing: add a public billing.add_fee() function, or have billing charge the fee when it hears order_placed.

Our check has limits. It catches normal imports and module._name, but not everything. It misses an alias (import billing as b, then b._charge()), and getattr, which looks up a name from a string. It's a guard rail, not a locked door. Real projects use bigger tools for the same job, such as Spring Modulith's verify step in Java or Shopify's Wedge for Ruby.

When should you split a module into a microservice?

Fowler's "MonolithFirst" article starts from what he saw in real teams: "Almost all the successful microservice stories have started with a monolith that got too big and was broken up". And the other way round: "Almost all the cases where I've heard of a system that was built as a microservice system from scratch, it has ended up in serious trouble."

One reason is that service borders are hard to move. "Any refactoring of functionality between services is much harder than it is in a monolith." Refactoring means reshaping code without changing what it does. Inside a modular monolith, moving a function from orders to billing is a normal code change in one app. Between services, it's two apps, two deploys and an API both sides must agree on. So learn where the borders really are while they're cheap to move.

Split a module out when it has a real reason, and its borders have been steady for a while:

  • It needs to scale on its own. Say a photo-resizing module needs ten times the computing power of everything else. As its own service, you can run more copies of just that part.
  • A separate team needs to release it on its own. When one module's team is always waiting for everyone else's deploy, its own service lets it ship on its own timetable.
  • It needs different technology. A machine-learning module might need a different language or a different kind of database.
  • Its public face has stopped changing. MonolithFirst says microservices "only work well if you come up with good, stable boundaries between the services". If you're still adding a new function to billing's public face every week, it isn't ready.

"It's the fashion" and "the app feels big" are not on the list. A modular monolith can be big. Shopify's is.

When the day comes, a modular monolith makes the move small, because the borders already exist. In our shop, moving billing out means: billing.can_pay() becomes an HTTP call to the new billing service (often through an API gateway), the order_placed event goes onto a message queue (a waiting line for messages between programs, so none are lost if billing is briefly down) instead of our in-process bus, and _wallets moves into billing's own database. Orders changes in two places. Nothing else in orders knows billing moved. Fowler describes this route: "The logical way is to design a monolith carefully, paying attention to modularity within the software, both at the API boundaries and how the data is stored. Do this well, and it's a relatively simple matter to make the shift to microservices."

He's honest that the advice isn't settled. In the same article: "However I'd feel much more comfortable with this approach if I'd heard a decent number of stories where it worked out that way." And: "While the bulk of my contacts lean toward the monolith-first approach, it is by no means unanimous." Some teams that already know their borders well, for example when they're rebuilding an old system, do start with services.

When is a modular monolith the wrong choice?

  • When the program is tiny. A 200-line script or a weekend project doesn't need modules, events and a border check. One file is fine. Add modules when a second business area shows up.
  • When you already have many teams and well-known borders. A large company rebuilding a system it understands, with separate teams ready to own separate parts, may get more from services from day one.
  • When one part really is different. A video-encoding job that needs special hardware, or a part that must keep running when the rest of the app is down, can be its own service from the start, even if everything else stays together.

Shopify's post sums up the order of things: "Monoliths, modular monoliths, and Service Oriented Architecture fall along an evolutionary scale as your application increases in complexity." Service oriented architecture is the older, broader name for building an app out of services.

What mistakes do beginners make with microservices and monoliths?

Starting with microservices for a new app. A college project or a new startup with five services, five databases and five deploys spends its time on network errors instead of features. It's the premium without the payoff. Start with one app and good modules.

Splitting by technical layer instead of business area. Folders called models/, views/ and services/ look tidy, but adding one feature, such as a discount, means touching all of them. Lewis and Fowler describe the same trap in teams: splitting by layer leads to "UI teams, server-side logic teams, and database teams", and then "even simple changes can lead to a cross-team project taking time and budgetary approval." Split by business area, and keep layers inside each module if you like.

Sharing tables. Walls in the code don't help if every module reads every table. When orders runs a SELECT (a database read) on billing's tables directly, billing can't change its tables without breaking orders. Fowler calls databases shared this way "a major source of nasty coupling in larger systems". Each module owns its tables, and others ask through its public functions.

1) After main.py has run, Ravi orders a 100-rupee pen: orders.place_order("ravi", "pen", 100). What does it return, and can Ravi pay for anything after that?

2) A teammate adds billing._charge(order_id, customer, 10) just before return order_id in the original orders/__init__.py (without the delivery-fee lines). Does check_boundaries.py catch it?

3) Add a shipping module that prints shipping order 1 to asha whenever an order is placed, without changing a single line of orders.

Show the answers

1) It returns 3. Ravi has exactly 100 rupees, and can_pay uses >=, so 100 is enough. Billing then charges him, leaving 0, so billing.can_pay("ravi", 1) is False.
2) Yes. It prints orders/__init__.py:16 uses billing._charge and exits with 1. The attribute rule catches billing._charge even though there's no import of it. And it's a real bug: billing already charges on the event, so this would charge twice.
3) Make shipping/__init__.py, import it at the top of main.py (before the first place_order call), and add "shipping" to MODULES in the check:

# shipping/__init__.py
"""Shipping's public face. Listens for new orders."""
import events


def _ship(order_id, customer, amount):
    print(f"shipping order {order_id} to {customer}")


events.subscribe("order_placed", _ship)

Orders still just publishes "order_placed". It never learns that shipping exists. That's the point of events.

Questions people ask

What is the difference between monolithic and microservices architecture?

A monolithic app is built and deployed as one unit, and its parts call each other as normal functions. A microservices app is many small apps, one per business area, each deployed on its own with its own database, calling each other over the network. Monoliths are simpler to run and debug. Microservices let parts change and scale on their own, at the cost of network failures, many deploys and split data.

What is a modular monolith in simple words?

One app, split inside into modules by business area, such as orders and billing. Each module hides its insides and shows a small public face. Modules talk only through that face, by calling public functions or publishing events, and each module owns its own data. It's one deploy, like a monolith, with clear borders, like microservices.

Is a monolith bad?

No. A monolith only means the app is built and deployed as one unit. Many successful products run as one. What's bad is a big ball of mud: a monolith with no borders inside, where every part reaches into every other part. A modular monolith is a monolith done well.

Is a modular monolith better than microservices?

For most new apps and small teams, yes, because you get clean borders without network failures, many deploys or split data. Microservices win when parts need to scale or release on their own, or when many teams work on one system. Many teams start modular and split out services later, one module at a time.

What is an example of a modular monolith?

Shopify's main Ruby on Rails app (Rails is a web framework for the Ruby language) is a well-known one. Its engineering team chose to keep all the code in one codebase but enforce boundaries between business areas, and built a tool called Wedge to report code that crosses those boundaries. Spring Modulith is a toolkit for building the same style in Java.

Do microservices need separate databases?

That's the usual rule. Microsoft's guide says two services shouldn't share a data store, and each should keep its own private data that others can't reach directly. If several services share one database, they're tied together through its tables, and you lose much of the point of splitting them.

Keep going

  • Previous in this System Design series: What is an API gateway?, the front door that sits in front of microservices.
  • Next: "Dependency injection", or how a module gets the things it needs without reaching out and grabbing them.
  • Where the series began, and the pattern most apps use inside each module: MVC architecture explained.
  • Build a service of your own: a REST API with Express.js.

Checked on 27 October 2026 against the 13 sources below.

Sources

Every Python sample in this post was run in Python 3.13 before publishing, and the boundary check was run against the shop both clean and broken.