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, exports only 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.