What a modular monolith and microservices actually are

A modular monolith is one deployable unit split into modules with explicit, enforced boundaries. Each module exposes a narrow public interface and owns its tables. Other modules cannot reach into its internals or query its schema directly. Without that enforcement, you have a big ball of mud that happens to be organised into folders.

Microservices are independently deployable units that communicate over the network, each owning its data store. Once module boundaries become process boundaries, every cross-boundary call can fail, time out, or return stale data.

MODULAR MONOLITH (one process)        MICROSERVICES (many processes)

+-------------------------------+     +---------+  HTTP/  +---------+
| Orders --fn call--> Billing   |     | Orders  |-------->| Billing |
|   |                   |       |     +----+----+  queue  +----+----+
|   v                   v       |          |                   |
| [orders schema] [billing sch.]|     [Orders DB]         [Billing DB]
+---------------+---------------+
                |
          [one database]
Aspect Modular monolith Microservices
Cross-module call In-process function call Network request or message
Failure modes Exceptions Timeouts, retries, partial failure
Data ownership Separate schemas, one database Separate databases
Deployment All modules ship together Each service ships alone
Cross-boundary transactions Possible, but should be avoided Require sagas or compensation

Teams adopt a modular monolith to keep the codebase organised without paying for distributed operations. They adopt microservices when they need to deploy, scale, or isolate failures independently.

This article's default is to start modular and split a module out only when you can name the specific pressure that requires it.

MODULAR MONOLITH: one process, one deployable unit
+------------------------------------------------------------+
|  +-----------+   function call   +-----------+             |
|  |  Orders   |------------------>|  Billing  |             |
|  |  module   |   in-process,     |  module   |             |
|  | (own API) |   errors are      | (own API) |             |
|  |           |   exceptions      |           |             |
|  +-----+-----+                   +-----+-----+             |
+--------|-------------------------------|--------------------+
         v                               v
+------------------------------------------------------------+
|  [orders schema]      one database     [billing schema]    |
|  each module owns its tables; no cross-schema queries      |
+------------------------------------------------------------+

MICROSERVICES: many processes, each deployed independently
+-----------+  HTTP / queue     +-----------+
|  Orders   |------------------>|  Billing  |
|  service  |  network hop:     |  service  |
+-----+-----+  timeouts,        +-----+-----+
      |  retries, partial failure     |
      v                               v
+-----------+                   +-----------+
| Orders DB |                   | Billing DB|
+-----------+                   +-----------+

In a modular monolith, modules call each other in-process and own separate schemas in one database; in microservices, each call crosses the network and each service owns its own database.

Finding module boundaries before they become network boundaries

Both architectures live or die on where you draw boundaries. Microservices don't make boundaries easier to find. They make mistakes more expensive to correct.

Start from capabilities and data ownership

Group code by business capability, such as billing, inventory, or fulfillment, rather than by technical layer. Then assign every table to exactly one module, which becomes the only code allowed to write to it. Everyone else asks that module, through its interface, for what they need. Data ownership is the sharper test of the two. If you cannot name a single owner for an entity, you have not found the boundary yet.

Signs a boundary is wrong

  • Chatty calls. Completing one request means module A calls module B many times in sequence. The logic is probably split across the line instead of sitting on one side of it.
  • Shared tables. Two modules read and write the same rows. That is coupling through the database, and an interface cannot hide it.
  • Changes that always span modules. If most features touch the same two modules together, they behave as one module and should probably be one.

Why to find out inside the monolith

Inside a monolith, fixing a bad boundary is a refactor. You move a class, change some function signatures, and the compiler and test suite tell you what broke. Everything ships in one deploy.

Once that boundary becomes a network contract, the same fix means versioned APIs, coordinated releases, and data migrations across separate stores. Teams often don't pay that cost, and the result is a distributed monolith. Services must deploy in lockstep, and a slow dependency stalls every caller upstream of it, so failures cascade. You end up with the coupling of a monolith and the operational cost of a distributed system.

How to implement and enforce boundaries inside a monolith

A module boundary that exists only in a diagram will not survive the first deadline. Make it something the compiler or the build can reject.

Expose a narrow public interface

Each module publishes one entry point: an interface or facade, plus the data types it accepts and returns. Everything else sits in an internal package that other modules cannot reach. Use language visibility where you can, such as package-private classes or unexported identifiers. Where you can't, use build-tool module visibility. Callers depend on what the module does and not on how it does it, so you can rewrite the internals without touching other teams' code. That narrow interface is also the contract you would put on the wire if you later extract the module as a service.

Give each module its own schema

Put each module's tables in a dedicated schema and grant its database role access to that schema only. A query that joins across schemas then fails at the permission check. This matters more than code visibility. A cross-module join couples two modules at the storage layer, where refactoring tools can't see it, and it is usually what blocks a clean extraction later.

Check dependency rules in CI

Write architecture tests that assert rules such as "nothing outside billing imports billing.internal", and fail the build when one breaks. A rule a reviewer has to remember gets skipped under pressure. A red build does not.

The trade-off is that nothing at runtime keeps the modules apart. The structure depends on discipline and tooling. The usual way it degrades is a "temporary" shared-table read to hit a release date. Treat any exception to these rules as tracked debt with an owner, not as a quiet fix.

-- PostgreSQL: one schema and one login role per module.
-- Each module's connection pool uses its own role, so the database
-- enforces the boundary even though it is one process and one database.

-- Stop modules from creating tables in the shared default schema.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

CREATE SCHEMA billing;
CREATE SCHEMA orders;

-- Credentials are set out of band, never in migrations.
CREATE ROLE billing_app LOGIN;
CREATE ROLE orders_app LOGIN;

-- billing_app can reach billing tables and nothing else.
GRANT USAGE ON SCHEMA billing TO billing_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA billing TO billing_app;
-- Cover tables created later by the role that runs migrations.
ALTER DEFAULT PRIVILEGES IN SCHEMA billing
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO billing_app;

-- Same pattern for orders.
GRANT USAGE ON SCHEMA orders TO orders_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA orders TO orders_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA orders
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO orders_app;

CREATE TABLE billing.invoices (
  id         bigint PRIMARY KEY,
  order_id   bigint NOT NULL,  -- plain ID, deliberately no FK into orders
  amount_due numeric(12,2) NOT NULL
);

-- Connected as billing_app, a shortcut join into another module fails:
--   SELECT i.id, o.status
--   FROM billing.invoices i JOIN orders.orders o ON o.id = i.order_id;
--   ERROR:  permission denied for schema orders
-- Billing must ask the orders module's public interface instead.

A PostgreSQL setup that gives each module its own schema and role. A cross-module join then fails at the permission check instead of quietly coupling two modules at the storage layer.

Decision criteria: team size, deployment independence, and scaling needs

Team size and ownership

Coordination cost scales with the number of teams sharing one codebase and one release train, not with lines of code. Two or three teams can usually settle merge order and release timing in a conversation. With many teams, a single failing module blocks everyone's release, and a split starts to pay off. That only applies when merge and release conflicts are recurring and you can trace them to team boundaries.

Deployment independence

Separate real needs from wanting faster deploys. Real needs include a different release cadence, isolating risk from a fragile component, and distinct compliance or runtime requirements. Slow deploys usually point to a CI problem. Parallel test runs, feature flags and trunk-based development often fix them without adding a network boundary.

Scaling needs

Running more copies of the whole monolith handles most load. Split a component out only when its resource profile is materially different (CPU-bound, memory-heavy, bursty or GPU-dependent) or when it has its own availability target.

Pressure Monolith response Split justified when
Many teams Module ownership, CODEOWNERS Release conflicts recur weekly
Deploy speed Better CI, flags Cadence or compliance differ
Load Horizontal scaling Resource profiles diverge sharply

Illustrative estimate (all inputs are assumptions):

  • 20 instances, each over-provisioned by $40/month to fit one memory-heavy module, cost about $9,600/year.
  • Extracting that module takes 6 engineer-weeks at $3,000/week, a one-time cost of $18,000.
  • Running it afterwards takes 10% of one engineer plus tooling, about $15,600/year.

Under these assumptions, paying for the extra memory stays cheaper than extracting the module.

Who should use a modular monolith, and who should use microservices

A modular monolith fits most teams. That includes:

  • New products and early-stage systems. Domain boundaries are still moving. You want to be able to move them with a refactor, not a cross-service migration.
  • Small to mid-sized engineering organisations. A handful of teams can share one release train, and nobody has to staff a platform team.
  • Teams without mature operations tooling. If you do not yet have distributed tracing, centralized logging, automated service provisioning, and on-call coverage per component, every extracted service adds that work to a product team.
  • Systems with strong consistency needs across capabilities. If many workflows need to change several capabilities atomically, one database transaction is far simpler than a saga.
  • Teams escaping a big ball of mud. Enforcing modules inside the existing deployable is the lower-risk first step. A rewrite into services is the higher-risk one.

Microservices fit a narrower group. They suit organisations where most of these conditions hold together:

  • Many teams with stable ownership of well-understood domains, and recurring release conflicts that you can trace to those team boundaries.
  • Components with clearly different scaling profiles, runtimes, availability targets, or compliance scopes.
  • A platform capability, whether a dedicated team or an equivalent investment, that provides service templates, deployment pipelines, observability, and service-to-service security, so that each new service does not start from zero.
  • Boundaries that have already held up as modules for a while. The safest extraction candidates are modules whose interfaces have stopped changing.

If you can only tick the first condition, fix ownership and release practices inside the monolith first. If you cannot tick any of them, microservices will cost more than they return.

Alternatives beyond the two-way choice

"Modular monolith or microservices" is a false binary. Several intermediate options solve specific pressures at lower cost:

  • Layered (unmodularised) monolith. This is a single codebase organised by technical layer: controllers, services, repositories. It is the cheapest option for a small team on a small domain. Its weakness is that nothing stops cross-capability coupling, so it tends to decay as the team grows. Modularising is the usual next step.
  • One artifact, several deployment roles. Build one artifact and start it in different modes, such as web, background worker, or scheduled jobs, chosen by a startup argument or configuration. Each role scales and fails independently. All roles share code, pipeline and observability. This answers many "this component needs its own scaling" arguments without a network contract between modules.
  • Coarse-grained services (sometimes called "macroservices" or "right-sized services"). Split along a few large domain lines, such as customer-facing commerce versus back-office finance, rather than one service per capability. You get deployment independence where it matters and keep the number of network boundaries small.
  • A monolith plus a few extracted services. Keep the core as a modular monolith and extract only the components with a clear reason: a GPU-bound inference worker, a high-volume ingestion path, a component in a separate compliance scope. Most systems that "moved to microservices" successfully end up looking like this.
  • Serverless functions for edge workloads. Event-driven, bursty, or rarely invoked tasks, such as image processing or webhook handling, can run as functions next to a monolith. They add the same distributed-systems concerns as services, so treat each one as an extraction and not as a free addition.
  • Separate codebases per team, sharing libraries rather than a runtime. This is useful when teams build largely independent products that share some domain code. The cost moves to library versioning and keeping consumers upgraded.

The checklist later in this article orders these options from cheapest to most expensive for each pressure.

When to avoid microservices: the operational cost you must budget up front

Every network boundary turns a function call into a distributed-systems problem, and each problem needs tooling and code the monolith never required. Budget for these before the first extraction, not after the first outage.

Concern In a modular monolith After splitting
Locating a dependency Import statement Service discovery, health checks
Following a request Stack trace Distributed tracing with propagated trace IDs
Reading logs One process Centralized logging with correlation IDs
Changing an interface Compiler flags callers Contract versioning, backward-compatible rollouts
A slow dependency Blocked thread Timeouts, retries, idempotency, partial-failure handling
Atomic writes One DB transaction Sagas, outbox pattern, eventual consistency

Retries force idempotency. A timed-out request may have succeeded, so the retry must carry a key the receiver uses to deduplicate. The outbox pattern writes the event row in the same transaction as the business change, so a crash cannot commit one without the other. The example below shows both. Each fix brings new bug classes: duplicate deliveries, out-of-order events, compensations that fail halfway, and reads that return stale state.

Without a platform team or equivalent investment, every product team carries this overhead, and it repeats for each service. Local development needs stubs or a running cluster. Integration tests need several services up at once. Debugging spans repositories and dashboards.

The monolith has real weaknesses too. All modules share runtime fate, so one memory leak or exhausted connection pool takes everything down. A slow build or test suite also slows every team on every change. Compare those concrete pains against the costs above, not against an idealized split.

import json, time, uuid
import requests

def charge_with_retry(order_id, amount_cents, attempts=3):
    # One key per logical operation, reused on every retry, so the payment
    # service can deduplicate a request that timed out but actually succeeded.
    key = str(uuid.uuid4())
    for attempt in range(attempts):
        try:
            resp = requests.post(
                "",
                json={"order_id": order_id, "amount_cents": amount_cents},
                headers={"Idempotency-Key": key},
                timeout=(1, 3),  # connect and read timeouts, in seconds
            )
            resp.raise_for_status()  # 4xx/5xx are not retried here
            return resp.json()
        except (requests.ConnectionError, requests.Timeout):
            time.sleep(0.2 * 2 ** attempt)  # exponential backoff before retrying
    raise RuntimeError("payment service unavailable; leave order pending")

def place_order(conn, order_id, customer_id, total_cents):
    # The order row and the outbox row commit or roll back together,
    # so an event is never published for an order that was not saved.
    with conn:  # psycopg2 connection: commits on success, rolls back on error
        with conn.cursor() as cur:
            cur.execute(
                "INSERT INTO orders (id, customer_id, total_cents, status) "
                "VALUES (%s, %s, %s, 'pending')",
                (order_id, customer_id, total_cents),
            )
            cur.execute(
                "INSERT INTO outbox (id, aggregate_id, event_type, payload) "
                "VALUES (%s, %s, %s, %s)",
                (str(uuid.uuid4()), order_id, "OrderPlaced",
                 json.dumps({"order_id": order_id, "total_cents": total_cents})),
            )
    # A separate relay polls unpublished outbox rows and sends them to the broker.

A remote call with timeouts, backoff retries and a stable idempotency key, plus an outbox event written in the same database transaction as the order it describes.

How the right choice saves engineering time

The time savings come from avoiding work, not from making work faster. Each line below is a recurring cost that one architecture carries and the other does not.

Where a modular monolith saves time:

  • Cross-module changes ship in one commit and one deploy. A feature that touches orders and billing is one pull request, one review, and one release. Across services, the same change needs a backward-compatible interface change, a deploy of the provider, a deploy of the consumer, and later removal of the old contract.
  • Refactoring is mechanical. Renaming a method or moving a field across a boundary is handled by the compiler and IDE. Across services it is a versioned migration.
  • Debugging uses one stack trace. You reproduce the issue locally with one process and one database, instead of correlating logs and traces across several services.
  • Tests are cheaper to write and run. In-process integration tests replace contract tests, service stubs, and multi-service test environments.
  • Infrastructure work is paid once. One pipeline, one set of dashboards, one deployment configuration, and one on-call rotation, instead of one of each per service.
  • Boundary mistakes stay cheap. The enforcement described earlier finds wrong boundaries while fixing them is still a refactor.

Where microservices save time, when the conditions in the "who should use" section hold:

  • Teams stop waiting on each other's release trains and stop coordinating merges in a shared codebase.
  • A failing build or bad deploy in one service does not block unrelated teams.
  • Build and test cycles per service are smaller, so feedback per change is faster for each team.

Illustrative estimate (all inputs are assumptions, not measurements): assume a team ships 8 features a month that touch two capabilities, and that each such change costs an assumed extra 1.5 engineer-days across service boundaries for contract changes, staged deploys and cleanup. That is about 12 engineer-days a month spent on coordination that a modular monolith does not need. Now assume, for a different organisation, that release conflicts block an assumed 10 engineers for half a day each per week. That is roughly 20 engineer-days a month, which independent deployment could recover. Neither number is a benchmark. The point is to estimate both sides with your own inputs before deciding which cost is larger.

The incremental path at the end of this article also saves time. Each step is useful on its own, so you never spend months on a migration that delivers nothing until it is finished.

A decision checklist that maps pressures to responses

Each pressure has a cheapest effective response. Start there, and move to the next rung only when that response demonstrably fails.

Observed pressure Cheapest effective response Escalate when
Frequent merge conflicts in the same files Fix module boundaries; give each module a clear owner and public interface Conflicts persist after ownership is clear
One team's release blocks another's Feature flags plus trunk-based merging, so unfinished work ships dark Teams need different release cadences or rollback scopes
A hot component dominates load Scale the monolith horizontally, or run the same build as a dedicated worker role That component needs a different runtime, memory profile, or data store
Slow builds and test runs Split the build and test suite by module; run only affected targets The build graph cannot be cut cleanly
Isolation or compliance requirements Separate deployment of the same artifact with restricted entry points and credentials Auditors require a separate codebase, network segment, or data boundary
All of the above, concentrated in one area Extract a single service —

The order matters because each step down the table adds permanent cost. Fixing a boundary is a refactor you do once. Feature flags add some cleanup work. A worker role reuses your existing pipeline, observability, and deploy tooling: you change a startup argument, not your architecture. An extracted service brings its own pipeline, on-call surface, versioned API, and failure modes.

Choosing the smallest response also shows you whether the pressure was really architectural. If a module split ends the merge conflicts, you have saved months of distributed-systems work. If it does not, you now have a clean boundary to extract along, which is the subject of the next section.

An incremental migration path from monolith to extracted services

Treat migration as a sequence of steps, each of which leaves the system working and each of which you can stop at. Many teams find they never need to go past step three.

1. Modularize in place. Enforce the boundaries described earlier, using build-time dependency rules and public module interfaces. Rollback is cheap because it is all code. If the change proves wrong, revert the commit.

2. Split data ownership. Give each module its own schema and remove cross-module joins and foreign keys. This step costs the most, because shared tables are what usually make extraction impossible. Keep both read paths available until the new one has been verified.

3. Change how modules communicate. Move to interface calls or asynchronous events. A module that already talks through a contract can later talk across a network without its callers changing. Asynchronous events also expose latency and ordering assumptions while the problems are still cheap to fix.

4. Extract one module at a time, strangler-style. Put a facade in front of the module. The facade implements the module's interface and picks a backend per request based on a flag. For example, an InventoryFacade checks inventory.remote_enabled. If the flag is off, the facade calls the in-process module. If it is on, the facade calls the extracted service over HTTP and falls back to local only for reads you have verified as safe. Increase the flag's percentage gradually. Rollback means turning the flag off, with no redeploy.

This order puts the reversible, broadly useful work first. If the pressure goes away after step two or three, you are left with a well-enforced modular monolith, not a half-finished distributed system.

// Both the in-process module and the HTTP client implement the same contract.
interface InventoryService {
  getStock(sku: string): Promise<number>;
  reserve(sku: string, qty: number): Promise<void>;
}

// Minimal flag reader: returns the rollout percentage (0-100) for a flag.
interface Flags {
  percentage(name: string): number;
}

// Stable bucketing: the same SKU takes the same path while the rollout percentage stays the same.
function bucket(key: string): number {
  let h = 0;
  for (const c of key) h = (h * 31 + c.charCodeAt(0)) >>> 0;
  return h % 100;
}

export class InventoryFacade implements InventoryService {
  constructor(
    private local: InventoryService,  // in-process module
    private remote: InventoryService, // extracted service over HTTP
    private flags: Flags,
  ) {}

  private useRemote(sku: string): boolean {
    return bucket(sku) < this.flags.percentage("inventory.remote_enabled");
  }

  async getStock(sku: string): Promise<number> {
    if (!this.useRemote(sku)) return this.local.getStock(sku);
    // Reads are verified safe to serve locally, so fall back on remote failure.
    try { return await this.remote.getStock(sku); }
    catch { return this.local.getStock(sku); }
  }

  async reserve(sku: string, qty: number): Promise<void> {
    // No fallback for writes: retrying locally could split state across two stores.
    return this.useRemote(sku) ? this.remote.reserve(sku, qty) : this.local.reserve(sku, qty);
  }
}

A strangler-style facade sends each inventory call to the in-process module or the extracted service based on a percentage flag, falls back to the local module only for reads, and can be rolled back by setting the flag to zero.

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 modular monolith vs microservices, 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.