What a monolith is and what microservices actually cost

A monolith architecture packages the whole system as one deployable unit: a single monolithic application with one build, one release, and usually one primary database. Its modules call each other as ordinary in-process functions. Microservices split that system into independently deployed processes that communicate over the network. The modular monolith sits between the two. It keeps a single deployable but enforces strict internal boundaries, so modules own their data and expose narrow interfaces.

Concern Monolith / modular monolith Microservices
Cross-module call In-process function call Network request (serialization, latency)
Failure mode Whole process up or down Partial failure, timeouts, retries
Debugging a request One stack trace Distributed tracing across services
Locating dependencies Linker / import Service discovery
Changing an interface Refactor in one commit Versioned APIs, coordinated rollouts

Each row on the right is new work. When a function call becomes a network call, you have to decide what happens if the call is slow or never returns. That means writing timeout, retry, and idempotency logic that the monolith never needed. When one request touches five services, a stack trace no longer explains a bug, so you need tracing infrastructure and the discipline to propagate context through every hop. Interfaces that used to change in one refactor now need versioning, because callers deploy on their own schedules.

These costs are operational and organizational, and you pay them from the first day. The benefits are independent scaling and independent team ownership. They only pay off once you have many teams blocking each other or components with sharply different load profiles. Most organizations never reach that scale.

Why teams use it: simple deployment and single-transaction consistency

A monolithic application ships as one build artifact through one pipeline. That removes a whole category of work. You don't need a compatibility matrix between service versions, a release order to coordinate, or a contract test suite to keep consumers in sync. Rollback follows one path: redeploy the previous artifact. A change that touches billing, inventory, and notifications lands in a single commit and goes out in a single release. No feature flag is needed to cover the gap while three services catch up with each other.

The bigger payoff is data consistency. Take placing an order. In a monolith, inserting the order, decrementing stock, and recording the payment happen inside one database transaction. Either all three commit or none do. The database enforces this, so your code has no partial states to handle.

Split those tables across services and that guarantee is gone. The usual replacement is a saga, a sequence of local transactions in which every step needs a compensating action: release the reserved stock, refund the charge, mark the order cancelled. Each service also needs a reliable way to publish its event after committing. That typically means a transactional outbox: write the event to a table in the same local transaction, then have a separate relay deliver it.

The result is eventual consistency. Your code must now handle duplicate messages, compensations that fail themselves, and readers who see an order that exists before its payment does. Every one of those is a code path someone has to write, test, and debug.

import sqlite3

# Monolith: one local transaction. `with conn:` commits if the block
# finishes and rolls back if it raises, so a partial order can't exist.
def place_order(conn: sqlite3.Connection, order_id, sku, qty, amount):
    with conn:
        conn.execute("INSERT INTO orders (id, sku, qty, status) VALUES (?, ?, ?, 'placed')",
                     (order_id, sku, qty))
        cur = conn.execute("UPDATE inventory SET stock = stock - ? WHERE sku = ? AND stock >= ?",
                           (qty, sku, qty))
        if cur.rowcount != 1:
            raise ValueError("insufficient stock")  # also undoes the order insert
        conn.execute("INSERT INTO payments (order_id, amount) VALUES (?, ?)", (order_id, amount))

# Services: the same flow as an orchestrated saga (outline only).
# `orders`, `inventory` and `payments` are placeholder clients for remote services.
# Each call is a separate local transaction and can fail independently.
def place_order_saga(order_id, sku, qty, amount):
    orders.create(order_id, status="pending")        # compensation: orders.cancel
    try:
        inventory.reserve(order_id, sku, qty)        # compensation: inventory.release
    except Exception:
        orders.cancel(order_id)
        raise
    try:
        payments.charge(order_id, amount)            # compensation: payments.refund
    except Exception:
        # Compensations can fail too, so they must be retried until they succeed.
        inventory.release(order_id)
        orders.cancel(order_id)
        raise
    # If this call fails after the charge succeeds, retry it, or refund and release.
    orders.confirm(order_id)
    # Every step must also be idempotent, because retries and redelivered
    # messages will run it more than once. Each service publishes its
    # events through a transactional outbox, and until the saga finishes,
    # readers can see an order that has no payment.

The monolith places an order with one atomic database transaction, while the saga version needs a compensating action for every step, retries, idempotent steps and an outbox, and readers can see half-finished orders along the way.

Who should use it: small team architecture and Conway's law

Melvin Conway observed that systems tend to mirror the communication structure of the organizations that build them. The practical reading for architecture is that a service boundary works best when it matches a team boundary. One team owns the service, deploys it on its own schedule, and answers its pages. The network call between two services then reflects a real handoff between two groups of people who need a stable contract anyway.

When the boundaries don't line up, the cost shows up in maintenance work. Suppose four engineers own twelve services. Every one of those services has its own build pipeline, deployment configuration, dashboards, alert rules, on-call runbook, and dependency tree. A security advisory in a shared library becomes twelve upgrades, each with its own test run and rollout. An alert at 3 a.m. could come from any of them, so nobody knows any single one deeply. The team has taken on the coordination costs of a large organization without having the people to absorb them.

A monolith is the sensible default for:

  • Early-stage products, where domain boundaries are still moving and a wrong split is expensive to undo.
  • Small and mid-sized teams, where everyone can reasonably understand the whole codebase.
  • Organizations where one or a few teams share a single domain, since a split would put a network boundary between people who already sit together.

Headcount changes this. When several teams start blocking each other on releases, fighting over merge conflicts in shared modules, or needing different deployment cadences, the organization has grown its own boundaries. That is when a matching service boundary starts to pay for itself.

How to build a modular monolith

Until that point, the goal is to make the monolith's internal boundaries as sharp as service boundaries, without the network between them.

Boundaries in the package structure

Give each business capability, such as billing, catalog or accounts, its own top-level package. Split each one into two parts: a small public interface, and an internal package that holds everything else. Other modules may call only the public interface. This matters because coupling rarely comes from deliberate design. It builds up through convenient imports of whatever class happened to be nearby. When the public interface is the only supported entry point, every cross-module dependency is a visible choice that can be reviewed.

Each module owns its tables

A module reads and writes only its own tables. If orders needs a customer's address, it asks the accounts interface for it and does not join against the accounts tables. Sharing tables is where most monoliths quietly fuse together: one schema change breaks three teams, and you can no longer tell which module owns which data. Keeping data ownership strict also means a later extraction moves the data along with the code.

Dependency rules that fail the build

Write down the allowed dependency directions. For example, orders may depend on catalog and accounts, but neither may depend on orders. Enforce the rules in CI with a short script that scans source files and fails on two conditions: any import that reaches into another module's internal package, and any import that points in a forbidden direction. Code review misses these violations under deadline pressure. A failing build does not.

# check_boundaries.py: run in CI as `python check_boundaries.py`; a nonzero exit fails the build.
# Assumed layout: src/<module>/api.py is the public interface and
# src/<module>/internal/ holds everything else in that module.
import re
import sys
from pathlib import Path

ROOT = Path("src")

# Allowed dependency directions: each module lists the modules it may import.
# orders may depend on catalog and accounts. Neither may depend on orders.
ALLOWED = {
    "orders": {"catalog", "accounts"},
    "catalog": set(),
    "accounts": set(),
}

# Matches the first module after `import` or `from` at the start of a line.
# This is a deliberately simple check, not a full parser.
IMPORT = re.compile(r"^\s*(?:from|import)\s+([\w.]+)", re.MULTILINE)

errors = []
for path in ROOT.rglob("*.py"):
    owner = path.relative_to(ROOT).parts[0]
    for target in IMPORT.findall(path.read_text(encoding="utf-8")):
        parts = target.split(".")
        module = parts[0]
        if module not in ALLOWED or module == owner:
            continue  # stdlib, third-party, or an import within the same module
        if "internal" in parts[1:]:
            errors.append(f"{path}: imports {target}, which is internal to {module}")
        elif module not in ALLOWED.get(owner, set()):
            errors.append(f"{path}: {owner} is not allowed to depend on {module}")

for error in errors:
    print(error)
sys.exit(1 if errors else 0)

A short CI script that fails the build when code imports another module's internal package or depends on a module in a direction the rules forbid.

How a monolith scales in practice

The first scaling step is horizontal: run several identical copies of the application behind a load balancer. This only works if each instance is stateless. Sessions, uploaded files and in-progress work must live in a shared store such as the database, a cache server or object storage, not in process memory or on local disk. Once any instance can serve any request, adding capacity means starting another copy.

The database usually becomes the bottleneck next. Most applications read far more than they write, so read replicas can take the reporting queries, search pages and listing endpoints off the primary. Replication is typically asynchronous, so a replica can lag behind. Any path that must read its own writes, such as showing a user the record they just saved, should still query the primary.

Caching reduces load at two layers. At the application layer, store expensive query results or computed values with explicit expiry and invalidation rules. At the HTTP layer, Cache-Control and ETag headers let browsers, proxies and CDNs serve repeat requests without reaching your instances at all.

The trade-off is that you scale coarsely. If one CPU-heavy feature is saturated, you still replicate the whole application, along with memory reserved for code paths that are idle. Worker processes reduce this waste. They are built from the same codebase and the same artifact but started with a different entry point, and they consume jobs from a queue. You can scale report generation or image processing on its own, with its own instance count and resource limits, while the deployment pipeline and the domain code stay shared.

Impact on development cost and engineering time

The cost difference shows up in maintenance. Every independently deployed service carries its own operational surface, and that surface grows linearly with the number of services, regardless of how small each service is.

Illustrative estimate (assumed numbers, not a benchmark)

Assume a team of eight engineers. Assume each deployable needs one pipeline, one dashboard, one alert set, one on-call runbook, and one dependency upgrade pass per quarter. Assume each upgrade pass takes 4 engineer-hours.

Formula: artifacts to maintain = deployables × artifacts per deployable; quarterly upgrade hours = deployables × hours per pass

Item 1 monolith 6 services
CI/CD pipelines 1 6
Dashboards 1 6
Alert sets 1 6
On-call runbooks 1 6
Upgrade passes per quarter 1 (4 h) 6 (24 h)

The table shows a sixfold difference. In practice the gap is wider, because services also need contract tests, versioned APIs, and tracing across network hops. A monolith needs none of these.

The monolith has its own costs. Build and test times grow with the codebase, and without test selection, build caching, or parallel test runners, feedback loops slow until engineers stop running the suite locally. Schema migrations on a shared database also need coordination. One team's column rename can break another module's queries, so migrations have to be backward-compatible and rolled out in expand-then-contract steps. Both costs are real, but your team can fix them with tooling and discipline. The per-service overhead in the table only shrinks if you run fewer services.

When to avoid it and how to extract a service safely

Some costs, though, are structural, and no amount of discipline removes them:

  • Weak fault isolation. Every module shares one process, so a memory leak in reporting or a runaway query in search can exhaust the heap, threads, or connection pool that checkout depends on.
  • One shared deployment. A bad change in one module blocks the release or forces a rollback that removes every other team's work with it.
  • One stack and runtime. A workload that needs a different language, garbage-collection profile, or hardware still has to fit inside the main application.
  • Coordination at scale. With many teams in one codebase, merge conflicts, release negotiation, and blurred ownership grow faster than headcount.

These trade-offs justify extraction only when they actually hurt:

Signal Justifies extraction Does not
Scaling One workload needs GPUs or far more replicas than the rest "The codebase feels big"
Isolation Regulated data must sit inside a separate, audited boundary A preference for cleaner separation
Team cadence A team routinely waits on other teams' releases to ship Occasional merge conflicts

To extract safely, start from a module whose boundary is already clean, with no reaching into other modules' internals or tables. Untangling and extracting at the same time doubles the risk. Use the strangler pattern: put a routing layer in front, send a small share of traffic to the new service, compare the results, and widen the share as confidence grows. Treat data ownership as a separate, deliberate step. Migrate the module's tables to the new service, or let other code reach them only through the service's API. If other code can still query those tables directly, you have a distributed monolith: the network costs of services without the independence.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to monolith architecture, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.