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.