What a modular monolith is and how it differs from layered monoliths and microservices
A modular monolith is a single deployable unit, usually running as one process, whose code is split into modules that each own a business capability such as billing, catalog or fulfilment. Each module exposes a deliberate public interface and keeps its internals and data private. The architecture still has one build, one release and one runtime. The difference is that the internal seams are explicit and enforced, not left to convention.
A layered monolith slices the code by technical role: controllers, services, repositories. A change to "how refunds work" touches every layer, and any service can call any repository. Coupling grows silently because nothing marks where one business area ends.
The modular monolith borrows the organizing principle that microservices are known for, which is building around business capabilities, but it drops the network boundary. Calls between modules are in-process function calls. That means no serialization, no partial failures and no distributed transactions.
It suits teams that have:
- one product
- a shared release cadence
- a domain they are still learning
When boundaries are wrong, moving code between modules is a refactor, not a cross-service migration.
| Aspect | Layered monolith | Modular monolith | Microservices |
|---|---|---|---|
| Deployment unit | One | One | One per service |
| Boundary enforcement | Convention only | Build/CI rules | Network and process |
| Data ownership | Shared schema | Per module, same database | Per service |
| Fault isolation | None | None at runtime | Per process |
| Scaling | Whole app | Whole app | Per service |
| Operational overhead | Low | Low | High |
How to draw module boundaries
Start from business capabilities, not technical layers. Fowler and Lewis describe microservices as organized around business capabilities, and the same principle applies inside one deployable. "Billing", "Catalog" and "Fulfilment" are candidate modules. "Repositories" and "Controllers" are not. Bounded contexts from domain-driven design help here. When the same word means different things to different parts of the business, such as an "order" to sales versus to the warehouse, that difference in meaning usually marks a boundary.
Signals that a boundary is right
- Data and behaviour change together. Tables, rules and workflows that are always edited in the same pull request belong in the same module. If a change to one table routinely forces changes in another module's code, the boundary splits something that is really one unit.
- Change frequency. Keep volatile areas, such as pricing rules, apart from stable ones, such as identity, so that churn in one does not destabilise the other.
- Team ownership. A module should have one owning team. If two teams must coordinate on every change, the boundary is in the wrong place.
A module touched by nearly every feature is the clearest warning sign. It usually means a "shared" or "common" module has absorbed domain logic, or that a real capability is spread across several modules.
Start coarse
Wrong boundaries are costly to fix. Data has to move between schemas, public APIs get renegotiated, and callers must be updated everywhere. Merging two modules that turn out to be inseparable is harder than splitting one that has grown too large. When the domain is poorly understood, draw a few large modules first and split them once real change patterns emerge.
Public APIs, data ownership and communication between modules
Each module exposes one small public surface, a facade or service interface plus the DTOs it accepts and returns, and hides everything else. Let the compiler enforce this, because conventions erode under deadline pressure:
- Java: keep implementation classes package-private. With JPMS,
exportsonly the API package. - C#: give each module its own assembly and mark implementation types
internal. - Go: place implementation under the module's
internal/directory, which the toolchain blocks other import paths from reaching. - Monorepos: make each module a separate package that publishes only its entry point.
Data follows the same rule. Modules share one database, but each owns a schema and connects through its own role, which is granted rights only on that schema. Avoid cross-module joins and foreign keys. They tie two storage layouts together and are the first thing that blocks a later extraction. For queries that span modules, call each module's API and combine the results (API composition). Alternatively, keep a read model: a projection owned by the querying module and updated from other modules' events.
┌──────────────────── Host process ────────────────────┐
│ [Orders API] [Billing API] [Catalog API] │
│ Orders impl ──► Billing impl Catalog impl │
│ │ └── events ──► (handlers) │ │
└──────┼───────────────────┼──────────────────┼────────┘
schema: orders schema: billing schema: catalog
role: orders_rw role: billing_rw role: catalog_rw
Use synchronous in-process calls when the caller needs an answer immediately. In-process domain events fit "this happened" notifications, and they reverse the dependency so the publisher doesn't need to know who is listening. Avoid a single transaction across modules, because it couples their consistency and failure handling. Instead, write each event to an outbox table in the same transaction as the state change, and let a dispatcher deliver it after commit. Delivery is at-least-once, so handlers must be idempotent.
-- PostgreSQL: one database, one schema and one role per module.
-- Each role owns only its schema, so cross-module reads fail at the database.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
CREATE ROLE orders_rw LOGIN PASSWORD 'placeholder-orders';
CREATE ROLE billing_rw LOGIN PASSWORD 'placeholder-billing';
CREATE SCHEMA orders AUTHORIZATION orders_rw;
CREATE SCHEMA billing AUTHORIZATION billing_rw;
-- Unqualified names resolve to the module's own schema.
ALTER ROLE orders_rw SET search_path = orders;
ALTER ROLE billing_rw SET search_path = billing;
-- Orders data. Columns that refer to other modules hold plain IDs,
-- with no foreign key into catalog or billing.
CREATE TABLE orders.orders (
id uuid PRIMARY KEY,
customer_id uuid NOT NULL,
total_cents bigint NOT NULL
);
-- Outbox: written in the same transaction as the business change.
-- A relay reads unpublished rows and dispatches them to event handlers.
CREATE TABLE orders.outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_type text NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
ALTER TABLE orders.orders OWNER TO orders_rw;
ALTER TABLE orders.outbox OWNER TO orders_rw;
-- Run as orders_rw: the order and its event commit together, or neither does.
BEGIN;
INSERT INTO orders (id, customer_id, total_cents)
VALUES ('6f1c2a4e-0000-4000-8000-000000000001', '9b7d3e10-0000-4000-8000-000000000002', 4999);
INSERT INTO outbox (event_type, payload)
VALUES ('OrderPlaced', '{"orderId": "6f1c2a4e-0000-4000-8000-000000000001", "totalCents": 4999}');
COMMIT;
PostgreSQL setup that gives each module its own schema and role with no cross-module foreign keys, and writes an order and its OrderPlaced outbox event in one local transaction.
Enforcing dependency rules in the build and CI
Folder names are a convention, and conventions lose to deadlines. When orders needs a customer's credit limit and the billing repository class is one import away, someone will import it, and the compiler will accept it. A few months of this produces a graph where every module reaches into every other, and the boundaries exist only in the directory tree. The fix is to make a violation fail the build.
Keep the graph acyclic
Module dependencies must form a directed acyclic graph. A cycle between two modules means neither can be changed, tested, or later extracted without the other, so they are effectively one module. Treat any new cycle as a build failure.
Keep the shared kernel small and owned
The shared kernel should hold only stable primitives such as money, identifiers, and clock abstractions. Give it a named owner who reviews every change. A kernel without an owner becomes a dumping ground, and every module then depends on everything in it.
Let the build system and tests enforce it
Declare each module as a separate build unit, such as a Gradle subproject or Maven module. Undeclared dependencies then fail compilation. In Go, place a module's implementation under modules/billing/internal/. The toolchain rejects imports of that package from outside modules/billing. In Java, ArchUnit tests can assert that no class outside billing.api is referenced from another module.
Apply the same rule to data. Give each module its own database role with privileges only on its own schema. A cross-module join then fails at runtime as well as in review.
package com.example.shop;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
// Runs with the normal test suite, so a boundary violation fails the build.
@AnalyzeClasses(packages = "com.example.shop")
class ModuleBoundaryTest {
// Each top-level package under com.example.shop is one module.
// A cycle between any two modules fails this test.
@ArchTest
static final ArchRule modulesAreAcyclic =
slices().matching("com.example.shop.(*)..").should().beFreeOfCycles();
// Only billing.api is public. Everything else in billing is internal.
// Example: orders importing a billing repository fails this rule.
@ArchTest
static final ArchRule billingInternalsArePrivate =
classes().that().resideInAPackage("com.example.shop.billing..")
.and().resideOutsideOfPackage("com.example.shop.billing.api..")
.should().onlyBeAccessed().byAnyPackage("com.example.shop.billing..");
// The shared kernel must not depend on any module.
// This keeps it a set of stable primitives that do not grow inward.
@ArchTest
static final ArchRule sharedKernelStaysLeaf =
classes().that().resideInAPackage("com.example.shop.shared..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("com.example.shop.shared..", "java..");
}
This ArchUnit test runs with JUnit 5 and fails the build in three cases: the module graph has a cycle, a module reaches past billing.api into billing's internals, or the shared kernel depends on code outside itself and the JDK.
Implementing it: repository layout, cross-cutting concerns and testing
Repository layout
Give each business module its own top-level folder or build module, such as billing/, catalog/ and orders/. Inside each one, keep api, implementation, persistence and tests side by side. When everything a module owns sits in one place, a reviewer can see from the diff alone whether a change stays inside its boundary, and deleting or extracting the module later is a directory operation rather than an archaeology project.
Next to the modules, keep a thin host, the composition root. It reads configuration, builds each module through its public API, connects the modules' ports to one another and starts the process. It contains no business logic. If a rule ends up in the host, it has no owner.
Cross-cutting concerns
Authentication belongs at the edge, in the host. It verifies the caller once and passes an identity into the modules. Authorization stays inside each module, because only billing knows who may issue a refund. Logging and tracing come from a shared library that every module uses, so that log fields and correlation IDs stay consistent. The host loads configuration and hands each module only its own typed settings. No module reads global configuration directly.
Testing
Most tests should be module-scoped. Unit and integration tests exercise the module's public API against its real schema, with the other modules replaced by fakes of their APIs. On top of that, add a small set of end-to-end tests through the composed application to prove the wiring works. Because each module's suite runs on its own, CI can run only the suites for the modules a change touches, which keeps feedback fast as the codebase grows.
// host/main.ts: the composition root. It only wires modules; it holds no business rules.
import { createCatalogModule } from "../catalog/api";
import { createBillingModule } from "../billing/api";
import { createOrdersModule } from "../orders/api";
import { createLogger } from "../shared/logging";
import { loadConfig } from "./config";
import { startHttpServer, verifyToken } from "./edge";
// The host is the only code that reads the environment.
const config = loadConfig(process.env);
// Every module uses the same logger, so fields and correlation IDs match.
const logger = createLogger({ service: "shop" });
// Each module receives only its own typed settings.
const catalog = createCatalogModule({ db: config.catalog.db, logger });
const billing = createBillingModule({
db: config.billing.db,
paymentGatewayUrl: config.billing.paymentGatewayUrl,
logger,
});
// Orders declares ports. The host fills them with other modules' public APIs.
// In orders' own tests, fakes take the place of these.
const orders = createOrdersModule({
db: config.orders.db,
logger,
prices: catalog.api, // implements orders' PriceLookup port
payments: billing.api, // implements orders' PaymentPort
});
// Authentication runs once at the edge. Modules receive a verified identity
// and make their own authorization decisions.
startHttpServer({
port: config.http.port,
authenticate: (req) => verifyToken(req.headers.authorization, config.auth),
routes: [...catalog.routes, ...billing.routes, ...orders.routes],
logger,
});
A thin composition root that loads configuration, gives each module only its own settings and a shared logger, connects the orders module's ports to the catalog and billing public APIs, and authenticates requests at the edge.
When to avoid a modular monolith, and the alternatives
That fast feedback is what a modular monolith gives you. It can't give you isolation at runtime. Every module runs in the same process and shares the same heap, thread pool and connection pool, and usually the same database. If the reporting module leaks memory, checkout goes down with it. A CPU-heavy export slows every request. A long transaction in inventory can hold locks that billing is waiting on. Clean module boundaries in the code don't prevent any of this. You need separate deployables if you need any of the following:
- Fault isolation between parts of the system
- Scaling one part without scaling the rest
- A release schedule per team
- A different runtime or library version per team
| Option | Team size it suits | Operational cost | Consistency model | Team autonomy |
|---|---|---|---|---|
| Layered monolith | One small team | Lowest | ACID transactions anywhere | Low: most features change every layer |
| Modular monolith | Several teams, one codebase | Low: one deployable | ACID within a module, ACID or events across modules | Medium: teams own their code and schema but share releases |
| A few coarse services | A few teams with different runtime needs | Moderate | ACID within a service, eventual consistency between services | High within each service |
| Microservices | Many independent teams | Highest: needs deployment automation and per-service monitoring | Mostly eventual | Highest |
Inside one process, a transaction that spans modules is cheap, so teams tend to overuse it. When they do, the modules' schemas become coupled, and every one of those transactions has to be redesigned before a module can be extracted. Overusing events causes the opposite problem. You take on retries, idempotency, ordering and harder debugging even though one database could have made the change atomic. If two modules regularly need to change data atomically, check whether the boundary is in the wrong place. Use events for reactions that can safely lag behind the change that caused them, and write those events through an outbox so they commit atomically with the change.
Impact on development cost, engineering time and later extraction
Each of these rules adds work before you see the benefit. Someone has to design every module's public interface, build read models so modules stop reading each other's tables, and keep the CI dependency checks passing as the code changes. You pay that cost from day one, and the benefits come later. An engineer changing billing only needs to understand the billing module and the dependencies it declares. Tests can run against one module with its neighbours replaced. You still have one pipeline, one deployable and one database to run. A set of services would each need their own networking, monitoring, deployment and on-call.
When extraction is justified
Extract a module only when there is a specific reason to run it separately:
- It needs to scale differently from the rest of the app, for example CPU-heavy document rendering next to lightweight request handling.
- It needs its own release cadence, and the shared pipeline keeps holding it back.
- A team needs to own it end to end.
Clean boundaries make extraction cheaper, but you still have work to do. In-process calls become network calls, and network calls can time out, fail partway through or be delivered twice. Callers then need timeouts, retries and idempotent operations. An interface that worked well in process can become too chatty over the network. The module's tables also move to their own database. Any cross-module joins or shared transactions left over must be replaced with events or read models.
Illustrative estimate (assumed numbers, not a benchmark). Assume one checkout request makes 40 per-item price lookups, and each network round trip takes 2 ms.
Added latency = calls per request × round-trip time = 40 × 2 ms = 80 ms
In process, those calls cost close to nothing. Redesigning the interface as a single batch lookup brings the added latency down to 1 × 2 ms = 2 ms.
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 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.