What microservices and monoliths actually are
The microservice architectural style, as James Lewis and Martin Fowler describe it, builds a single application as a suite of small services. Each runs in its own process, is organized around a business capability, is deployed independently by automated machinery, and communicates over lightweight mechanisms, often an HTTP resource API (Fowler and Lewis). A monolith is the opposite on one axis only: the whole application builds and deploys as one unit. Being one unit says nothing about its internal structure. A monolith can be a tangle, or it can be cleanly divided.
That is why it helps to ask two separate questions. The deployment question is how many units you ship and run. The modularity question is how well the code separates concerns behind enforced boundaries. Teams often move to microservices because they want better modularity, but you can get modularity without network boundaries. A modular monolith keeps one deployable unit and splits the code into modules that each own their data and expose explicit interfaces. This gives you most of the design discipline of microservices while you keep in-process calls and local transactions.
| Monolith | Modular monolith | Microservices | |
|---|---|---|---|
| Deployment unit | One | One | One per service |
| Data ownership | Shared schema, any code touches any table | Each module owns its tables | Each service owns its datastore |
| Communication | In-process calls | In-process calls through module interfaces | Network calls (HTTP, messaging) |
| Transactions | Local ACID | Local ACID, even across modules | Distributed, usually eventual consistency |
| Operational burden | Low | Low | High: per-service pipelines, monitoring, network failure handling |
How each style works at runtime: in-process calls vs network hops
In a modular monolith, a request enters one process and moves between modules through ordinary function calls. The order module calls the inventory module on the same stack, in the same memory, usually inside one database transaction. Requests fail in only a few places: the process, the database, and the load balancer in front of them.
Split those modules into services and each call becomes a network hop. Every hop adds serialization, a connection, a timeout to tune, and a remote dependency that can be slow or down. Each service also owns its own database, so a single user action can now commit in several places. Lewis and Fowler call out synchronous calls between services as harmful for this reason: their latency and downtime add up along the call chain [1].
Illustrative estimate (assumed numbers, not a benchmark)
Assume an in-process call costs 0.001 ms, a network hop costs 2 ms, and each service is available 99.9% of the time. A request makes 5 sequential calls.
- Latency:
calls × per-call cost. In-process, 5 × 0.001 = 0.005 ms. Across services, 5 × 2 = 10 ms, before any retries. - Availability:
per-dependency availability ^ number of dependencies. 0.999⁵ ≈ 0.995, so about 0.5% of requests fail. That is roughly five times the failure rate of a single service.
The fix is to draw boundaries so that common operations complete inside one component. If most requests cross a particular boundary synchronously, the boundary is probably in the wrong place. Keep that work in one service, or use asynchronous events where the business can tolerate delay.
(A) MODULAR MONOLITH: one process, in-process calls, one transaction
+-----------------------------------------------------+
request --> | app process (memory shared, one stack) |
via LB | [Orders] --call--> [Inventory] --call--> [Payment] |
+--------------------------+--------------------------+
|
v
[ one database, one transaction ]
fails at: LB, process, database
(B) MICROSERVICES: every arrow is a network hop that can be slow or down
+---------+ hop +-------------+ hop +---------+
request --> | Orders |------->| Inventory |------->| Payment |
via LB +----+----+ +------+------+ +----+----+
| | |
v v v
[Orders DB] [Inventory DB] [Payment DB]
fails at: LB, 3 processes, 2 network hops, 3 databases, partial commits
Illustrative estimate (assumed inputs, not a measurement), 5 sequential calls:
in-process: 5 x 0.001 ms = 0.005 ms network: 5 x 2 ms = 10 ms
availability at 99.9% per dependency: 0.999^5 ~ 0.995 (~0.5% fail)
The same request as in-process calls in a modular monolith (A) and as network hops between services with separate databases (B); the illustrative estimate uses assumed inputs to show how each hop adds latency and failure probability.
Why teams split: the forces for and against microservices
Chris Richardson's Microservice Architecture pattern frames the decision as a set of competing forces. Some forces push subdomains into separate deployables. Others pull them back together. Neither set goes away. Splitting removes some costs and adds others.
| Force | Pushes toward | Signal that it applies to you |
|---|---|---|
| Simple components | Splitting | No one can hold the whole codebase in their head, and changes cause surprising side effects |
| Team autonomy | Splitting | Teams queue behind each other's releases or argue over a shared deploy window |
| Fast deployment pipeline | Splitting | Build and test time keeps growing, so engineers batch changes instead of shipping small ones |
| Multiple technology stacks | Splitting | Upgrading a framework or language version needs everyone to migrate at once |
| Segregate by characteristics | Splitting | One module needs far more memory, stricter uptime or tighter security scope than the others |
| Simple interactions | Keeping together | Core operations touch several subdomains, and debugging them already needs tracing across modules |
| Efficient interactions | Keeping together | Hot paths make many fine-grained calls or move large payloads between modules |
| Prefer ACID over BASE | Keeping together | Business rules require several entities to change atomically, and stale reads are unacceptable |
Read the table as a list of costs rather than a scorecard. A ten-person team on one release train has little to gain from autonomy, but it would pay the full price of distributed operations right away. An organization with many teams blocked on a shared pipeline faces the opposite trade. The useful question is which of these costs you are paying now, and which new ones you can absorb.
Who should use microservices, and when to avoid them
Microservices pay off when the organization already looks like the architecture. That means small, cross-functional teams that each own one or more subdomains, plus automated deployment pipelines mature enough to support continuous deployment (Richardson). Without that, independent deployability has nobody to use it.
The operational baseline matters just as much (Lewis and Fowler):
- Infrastructure automation, so a new service doesn't take a week to provision.
- Centralized logging and distributed tracing, so one request can be followed across process boundaries.
- Per-service monitoring.
- Design for failure: timeouts, retries and circuit breakers on every remote call. Any dependency can be slow or unavailable at any moment.
When to avoid them
Stay with a monolith in any of these situations:
- Small team. The coordination problem microservices solve doesn't exist yet.
- Unclear domain boundaries. Boundaries drawn too early get frozen into network contracts.
- Uniform load. If the whole system scales the same way, separate scaling buys nothing.
- Many operations need transactions across entities. These become distributed coordination problems once the data is split.
The worst outcome is a distributed monolith. Its services must be deployed together and call each other synchronously to finish a single request. You pay for network latency, partial failure and operational overhead, but you get neither independent deployment nor isolation.
Decision checklist
- How many teams change this code?
- How often do they block each other on deploys?
- Do parts have genuinely different scaling, availability or security needs?
- Can the team operate a distributed system today, not after the migration?
Data ownership and consistency across service boundaries
Decentralized data management means each service owns its tables, and no other service reads or writes them directly. Access goes through the owning service's API or events. This keeps services independently deployable, as Lewis and Fowler describe. The cost is that an operation spanning two services can no longer rely on a single database transaction.
Order and payment: one transaction vs a saga
In a monolith, placing an order is one ACID transaction. You insert the order, charge the payment record and decrement stock, then commit. If any step fails, the database rolls everything back, and no other request ever sees a half-finished order.
With separate Order and Payment services, you use a saga instead. A saga is a sequence of local transactions, each paired with a compensating action that undoes it:
- Order service creates the order as
PENDING. - Payment service charges the card.
- On success, the order moves to
CONFIRMED. On failure, the compensating action marks itCANCELLED.
Between steps, the system is visibly inconsistent: a pending order exists with no payment. Code and UI must handle that state. This is the trade-off behind the microservices.io force "prefer ACID over BASE."
Retries and idempotency
Messages get redelivered and calls time out, so every step must be idempotent. For example, the payment step should key on the order ID and do nothing if that charge already exists. Otherwise a retry charges the customer twice.
Why sagas hurt to test and debug
Failures occur between services, in orderings that are hard to reproduce locally. Tracing one order means correlating logs across processes. You also need tests for each partial-failure path, including the compensations, not just the happy path.
# Illustrative saga for placing an order. `orders`, `payments` and `gateway`
# stand in for each service's own store or API; they are not a real library.
def place_order(order_id, customer_id, amount_cents):
# Step 1: Order service local transaction. The order is visible as PENDING.
orders.create(order_id, customer_id, amount_cents, status="PENDING")
try:
# Step 2: Payment service local transaction (separate database).
charge_payment(order_id, amount_cents)
except PaymentDeclined:
# Compensating action: undo step 1 by cancelling, not deleting.
orders.set_status(order_id, "CANCELLED")
return "CANCELLED"
# Step 3: Order service local transaction confirms the order.
orders.set_status(order_id, "CONFIRMED")
return "CONFIRMED"
def charge_payment(order_id, amount_cents):
# Idempotent: keyed on order_id, so a retried or redelivered
# request returns the earlier result instead of charging twice.
existing = payments.find_by_order(order_id)
if existing is not None:
return existing
# Pass the same key to the gateway so a timeout-then-retry
# is also deduplicated on the provider side.
result = gateway.charge(amount_cents, idempotency_key=order_id)
# A unique constraint on payments.order_id guards against two
# concurrent retries both passing the check above.
payments.insert(order_id=order_id, charge_id=result.charge_id)
return result
Illustrative pseudocode for the order-and-payment saga. Each step is a local transaction, a declined payment triggers a compensating CANCELLED update, and the payment step is keyed on the order ID so retries do not charge the customer twice.
Implementing it incrementally: modular monolith first, then extract one service
Most of that cost can be deferred. Start with a modular monolith: one deployable, split internally into modules that follow business capabilities such as orders, billing and inventory. Each module publishes a narrow public interface, for example billing.chargeCustomer(orderId, amount) returning a result type. Its tables, repositories and helpers stay private. Other modules call that interface and never query billing's tables directly. A shared schema is the fastest way to turn a modular monolith back into a tangled one.
Enforce the rule mechanically. Add a CI check that fails the build when a file in orders imports anything from billing.internal, or reads a table owned by another module. Most ecosystems support this through an architecture test, a linter rule or package visibility settings. Code review alone will not hold the line under deadline pressure.
Extract a service only when you can name the force behind it. Examples include billing needing its own release cadence, or inventory needing to scale apart from everything else. Then work in this order:
- Route all callers through the existing interface, so the call site doesn't care whether the implementation is in-process or remote.
- Split the data and give the module its own schema or database, migrating reads and writes before any network hop exists.
- Shift traffic gradually to the new service behind that same interface.
After the extraction, measure deployment frequency and incident load for a while before you extract a second service. If neither one improved, more services won't help.
Boundaries decay by default. Review them on a schedule, the same way you review dependencies.
# tests/test_module_boundaries.py -- runs in CI with pytest.
# Each module (e.g. shop/billing) exposes its public interface from __init__.py,
# such as charge_customer(order_id, amount) -> ChargeResult.
# Tables, repositories and helpers live in shop/<module>/internal/ and stay private.
import ast
import pathlib
SRC = pathlib.Path(__file__).resolve().parent.parent / "src" / "shop"
MODULES = {"orders", "billing", "inventory"}
def imported_names(path):
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
yield alias.name
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
# Also check "from shop.billing import internal".
for alias in node.names:
yield f"{node.module}.{alias.name}"
def test_no_cross_module_internal_imports():
violations = []
for owner in MODULES:
for path in (SRC / owner).rglob("*.py"):
for name in imported_names(path):
parts = name.split(".")
# Forbidden: shop.<other_module>.internal...
if (len(parts) >= 3 and parts[0] == "shop"
and parts[1] in MODULES and parts[1] != owner
and parts[2] == "internal"):
violations.append(f"{path}: imports {name}")
# Fail the build so the boundary holds without depending on code review.
assert not violations, "Forbidden cross-module imports:\n" + "\n".join(violations)
A pytest architecture test that parses each module's Python files and fails CI if one module imports another module's private internal package, so modules can only talk through each other's public interfaces.
Impact on development cost and engineering time
Microservices add a fixed overhead for every service you run. A monolith's overhead grows with the number of teams working in it and with how coupled its modules become.
| Cost driver | Monolith | Microservices |
|---|---|---|
| Build and test | One pipeline that slows as the codebase grows | A pipeline per service to build, secure and maintain |
| Releases | Coordinated, so one broken change blocks everyone | Independent, once versioned APIs and contract tests exist |
| Operations | One set of dashboards, alerts and on-call rotation | Monitoring, alerting and an on-call owner per service |
| Stacks and hiring | One stack to patch and hire for | Polyglot stacks fragment tooling, hiring and security patching |
| Infrastructure | Scale the whole process | Independent scaling, often paid for when no one needs it |
| Cross-module change | One commit, one transaction | Coordinated changes across services and versioned APIs |
The monolith saves engineering time when work crosses module lines. One stack trace covers the whole request, a rename refactors every caller, and a database transaction replaces compensation logic. Its costs show up later as merge contention, long builds and an extraction bill. Each tangled dependency you allow makes that bill larger.
Microservices save time in a different place: teams wait less on each other's deploys. That benefit appears only after teams own clear domains and pipelines, provisioning and observability are automated. Until then, you pay the per-service overhead without getting the autonomy that justifies it.
You rarely need a full split. Other options include:
- a modular monolith with enforced boundaries
- a few coarse-grained services aligned to team ownership
- extracting only the components with divergent runtime needs, such as a GPU-bound job
Each one keeps overhead in proportion to the problem it solves.