Architecture

Modular Monolith

A modular monolith is one deployable with hard internal boundaries. You keep in-process calls, one transaction and one pipeline, while each business module owns its data and exposes a small public interface — so it can become a service later if it ever needs to.

intermediate14 min readUpdated Sep 16, 2026
src/modules/billing/index.ts
ts
// src/modules/billing/index.ts
export type { Invoice, InvoiceId } from "./domain/invoice.js";
export { BillingService } from "./application/billing-service.js";
export { onOrderPlaced } from "./application/order-handlers.js";

// Everything under ./domain and ./infra stays private.
// Other modules import from this file and nothing deeper.
Deployment
One deployable unit
Boundaries
Modules by capability
Data
One database, one owner per table
Communication
In-process interfaces and events
Superpower
ACID transactions
Failure mode
Big ball of mud

Why it matters

Why the modular monolith is the sensible default

One deploy, one pipeline

A single build, a single test suite and a single rollback. There is no orchestration, no service discovery and no network between modules.

Strong internal seams

Each business module owns its domain and data and exposes a narrow public interface. Boundaries are enforced by tooling, not by good intentions.

A path to extraction

Because modules talk through interfaces and events, any one of them can become its own service later without rewriting the others.

The big picture

The three rules that keep a monolith modular

Modules follow capabilities, each owns its data, and they speak only through a public interface or an event.

Module

Capability

One module per business capability, containing its own domain, application and infrastructure code behind a public surface.

Data ownership

Isolation

One database, but each module writes only its own tables or schema. Nobody joins across module boundaries.

Interface

Contract

Modules call a published interface or publish an event. Internals are private, which is what makes the seam real.

At a glance

What good module boundaries look like

Modules

Folders like billing, catalog and shipping, each self-contained.

Public interface

A single index file is the only thing other modules may import.

Owned tables

A module is the only writer to its tables or schema.

Domain events

A module announces facts so others can react without a direct call.

Acyclic graph

Dependencies point one way and never loop back.

Enforced boundaries

Lint rules fail the build when a module reaches into another.

Flow

A request through the modules

Everything happens in one process, so the common case is a single transaction and a direct function call instead of a network hop.

  1. 1

    The HTTP layer maps the request

    A controller validates input and translates it into a call on the owning module. It holds no business rules of its own.

  2. 2

    The owning module handles the use case

    Its application service loads the aggregate, enforces the rules and decides what changes. This is where the domain lives.

  3. 3

    The module uses its own data

    It reads and writes through its own repository, touching only the tables it owns. No other module's tables are involved.

  4. 4

    It calls another module's interface

    If it needs a fact or an action from another capability, it calls that module's public service or emits a domain event.

  5. 5

    The transaction commits

    When the work stays inside one module it is a single ACID transaction. Cross-module consistency uses events and an outbox.

  6. 6

    The response returns

    The controller maps the result back to HTTP. No serialization boundary, no timeouts, no partial failure between modules.

The complete guide

Modular Monolith: Everything you need to know

What a modular monolith is

A modular monolith is a single deployable application with strong internal module boundaries. From the outside it looks exactly like a monolith: one process, one build, one database, one deploy. On the inside it is organised into modules that own their data and expose a narrow public interface, and those boundaries are enforced rather than aspirational.

This is not a compromise or a stepping stone you settle for. It is a legitimate architecture that many systems should never leave. It keeps the properties that make software easy to build — in-process calls, one transaction, one pipeline, cheap refactors — and adds the discipline that keeps a growing codebase from turning into a tangle.

The distinction that matters is between one deployable and one unstructured blob. A traditional layered monolith has no notion of ownership: any controller can reach any table, and a change ripples across the whole codebase. A modular monolith says that the billing module is the only thing that understands invoices, and everyone else talks to it through a door it controls.

Why it is the sensible default

Most teams reach for microservices to solve problems they do not have. A modular monolith addresses the problems they do have — a codebase that is hard to change, unclear ownership, slow delivery — without adding a network between the parts.

The practical advantages are substantial:

  • In-process calls are free. A module calling another module is a function call, not a request with a timeout, a retry policy and a failure mode.
  • Transactions are real. A use case that touches one module commits atomically. There is no saga, no compensation and no window of inconsistency.
  • Refactoring is a commit. Moving a boundary, renaming a concept or merging two modules is ordinary work, not a migration with dual writes and a cutover.
  • Operations stay small. One build, one deploy, one set of logs, one on-call. This is not a minor point for a team of five.
  • The domain gets time to settle. You can defer boundary decisions until you understand the business, instead of freezing guesses into infrastructure.

The honest cost is that modules do not fail or scale independently, and one module’s bug can take the whole application down. For most teams and most stages of a product, that is a trade worth making.

Modules follow business capabilities

A module should map to a business capability, not a technical layer. Billing, catalog, shipping, identity and notifications are capabilities. controllers, services, repositories and utils are layers, and grouping by them produces the classic layered monolith where every feature change spans four directories.

A good module boundary has the same properties as a good service boundary:

  • It contains a coherent set of rules that change together.
  • It hides far more than it exposes.
  • It can be understood by one team without reading the rest of the system.

Concretely, each module is a folder with its own internal structure:

src/modules/billing/
  domain/          # entities, value objects, invariants
    invoice.ts
    money.ts
  application/     # use cases and orchestration
    billing-service.ts
    order-handlers.ts
  infra/           # persistence and external clients
    invoice-repository.ts
    stripe-client.ts
  index.ts         # the only public entry point

The domain and infra folders are private. index.ts re-exports the small set of types and services the rest of the application is allowed to use. That single file is the module’s contract, and it should be small enough to read in a minute.

If you have ever seen the Clean Architecture dependency rule, this is the same idea applied at the module level: the domain at the centre depends on nothing outward, and infrastructure depends on the domain, not the other way around.

The shared kernel

Some concepts genuinely belong to no single module. Money, a CustomerId, a Clock or a Result type are used everywhere and owned by nobody. Put these in a small, explicit shared package and keep it deliberately tiny.

src/shared/
  money.ts        # value object, no dependencies
  ids.ts          # branded id types
  clock.ts        # a testable time source

A shared kernel is a coupling point, so treat its growth as a warning sign. The moment shared contains a service, a repository or anything that knows a business rule, it has become a module with no owner and every other module now depends on it. The rule of thumb is that shared holds types and pure functions, never orchestration or state.

Every module owns its data

The rule that gives modular monoliths their power is data ownership. Each module is the only writer to its tables or its schema, and no other module reads those tables directly. Cross-module access goes through the owning module’s interface or through an event.

In a single database you have three practical ways to express ownership:

  • Separate schemas. billing.invoices, catalog.products, shipping.shipments. The clearest signal, and it maps cleanly onto a future database split.
  • Table prefixes. billing_invoices, catalog_products. Simpler, same intent.
  • Separate databases from the start. The strongest boundary, but it costs you the single-transaction superpower across modules and adds operational work.

Most modular monoliths should start with one database and separate schemas. The key is the ownership rule, not the physical separation: only the owning module’s repository touches its tables. If the shipping module needs a customer’s address, it asks the customer module, it does not SELECT from customers.

This is what makes extraction possible later. When a module already owns its data and speaks through an interface, moving it to its own service is a deployment change rather than a redesign.

Enforcing boundaries with tooling

Boundaries that are only a convention erode. Under deadline pressure, importing another module’s repository is always faster than adding a method to its interface, and one shortcut becomes twenty. Make the boundary mechanical.

A dependency rule is easy to state and easy to check: a module may import its own files and the public interface of other modules, and nothing else. Tools like eslint-plugin-boundaries and dependency-cruiser can express this and fail the build.

{
  "forbidden": [
    {
      "name": "no-cross-module-internals",
      "from": { "path": "^src/modules/([^/]+)/" },
      "to": {
        "path": "^src/modules/(?!$1)([^/]+)/(?!index\\.ts).+"
      }
    },
    {
      "name": "no-cycles",
      "from": {},
      "to": { "circular": true }
    }
  ]
}

Two rules do most of the work: no importing another module’s internals, and no circular dependencies. Add a CODEOWNERS entry per module so reviews land with the people who own the code, and treat an interface change like a small API change — it deserves a second look.

In-process communication without a tangle

The failure mode of a monolith is a dependency graph where everything points at everything. A modular monolith keeps that graph acyclic and shallow. There are two ways for one module to use another.

Call the public interface. When the caller needs an answer immediately, inject the other module’s service and call it. Orders calls catalog.getProduct(sku) to price a line. The call is synchronous and in-process, so it is fast and fails as an exception, not a timeout.

export class PlaceOrder {
  constructor(
    private readonly orders: OrderRepository,
    private readonly catalog: CatalogService,
  ) {}

  async execute(input: PlaceOrderInput): Promise<OrderId> {
    const order = Order.create(input.customerId, input.lines);
    for (const line of order.lines) {
      const product = await this.catalog.getProduct(line.sku);
      if (!product.isAvailable()) throw new OutOfStock(line.sku);
      order.priceLine(line, product.priceCents);
    }
    await this.orders.save(order);
    return order.id;
  }
}

Publish an event. When the caller does not need an answer, it announces a fact and other modules react. Orders publishes order.placed; billing, analytics and notifications subscribe. The order module does not know they exist, which is what removes the coupling.

Events also break cycles. If orders needs a side effect from shipping and shipping already depends on orders, a direct call would create a loop. An event lets shipping react without orders depending on it. Keep the number of direct cross-module calls small; if two modules call each other constantly, they are probably one module or their boundary is in the wrong place.

One database, separate schemas

A single database is a feature, not a compromise. It gives you transactions, foreign keys, one connection pool and one migration story. What you give up is physical isolation, and you replace it with the ownership rule.

Within one database, prefer a schema per module. It keeps table names clean, makes ownership visible in every query, and gives each module a namespace for migrations. When a module is later extracted, its schema moves with it.

Avoid cross-module foreign keys. A foreign key from billing.invoices to catalog.products hard-couples the two modules at the database level: catalog cannot delete or restructure without considering billing, and extraction requires dropping the constraint. Store the id and validate it through the interface instead. The PostgreSQL guide covers schemas and constraints in detail.

Migrations deserve the same discipline. Each module owns its migration files, and the application startup or a migration step applies them in order. Because there is one deploy, you can migrate and release together, which is a luxury services do not have.

Transactions and consistency inside a module

The single-transaction superpower only holds when a use case stays inside one module. Make that the common case. A use case that loads an aggregate, validates an invariant and writes it back should be one transaction that either commits fully or rolls back cleanly.

await db.transaction(async (tx) => {
  const order = await orders.getForUpdate(orderId, tx);
  order.confirm();
  await orders.save(order, tx);
  await outbox.add(
    { type: "order.confirmed", orderId: order.id },
    tx,
  );
});

Two patterns keep cross-module consistency honest.

The outbox. Write the domain event to an outbox table in the same transaction as the state change, then a dispatcher publishes it afterwards. This guarantees the event is not lost if the process crashes between the commit and the publish, and it is the same pattern you would use after extraction.

A process manager. When a flow spans modules, a small coordinator can listen for events and issue the next command, handling retries and timeouts explicitly. This is the in-process equivalent of a saga, and it is far simpler than a distributed one because the coordination state lives in a normal table.

Do not reach for a distributed transaction. If a use case genuinely spans modules and must be atomic, that is usually a signal that the modules are one module.

The path to extraction

The reason to invest in boundaries is optionality. A modular monolith whose modules communicate through interfaces and events can be taken apart later, one module at a time, with the strangler fig approach.

  1. Pick the module with the clearest boundary and the strongest pressure. Independent scaling, a separate team cadence or a compliance requirement are good reasons.
  2. Confirm it owns its data. If other modules still read its tables, fix that first by routing them through the interface.
  3. Give it its own database and pipeline. Move its schema, point its repository at the new store, and keep the interface stable.
  4. Replace in-process calls with network calls or events. The callers already depend on an interface, so this is an adapter change, not a rewrite.
  5. Swap the event dispatcher for a broker. The outbox pattern means the event flow barely changes when the transport becomes Kafka or RabbitMQ.

Because the seam already exists, each step is bounded. This is the strongest argument for the modular monolith: it is not a different destination from microservices, it is the option to arrive there deliberately, only for the parts that earn it.

Testing modules in isolation

Module boundaries pay off in tests. Because a module exposes a public interface and owns its data, you can test it on its own without booting the whole application.

  • Domain tests are pure and fast. Instantiate the aggregate, exercise the rules, assert the outcome. No database, no HTTP.
  • Module interface tests drive the module’s public service against a test database scoped to its schema. They verify the contract other modules depend on.
  • Event contract tests assert that the module publishes the events consumers expect, with the fields they rely on.
  • End-to-end tests exercise the HTTP layer for a small number of critical flows. Keep these few, because they are slow.

The layered alternative forces every meaningful test through all layers, which is why those suites become slow and flaky. Testing at the module boundary keeps most tests fast while still protecting the interfaces that matter.

Modular monolith versus layered monolith

It is worth being precise, because both are “a monolith” and only one is modular.

Layered monolith Modular monolith
Grouping By technical layer By business capability
Change impact Spans every layer Stays in one module
Data access Any layer reaches any table Module owns its tables
Ownership Unclear One team per module
Testability End-to-end dominates Module-scoped tests
Extraction A rewrite A bounded change

The layered monolith is not wrong for a small application; it is simple and familiar. It becomes a liability when many people work in it, because there is no seam along which to divide the work and no way to reason about a change locally.

The failure mode: a big ball of mud

The modular monolith fails in one specific way: the boundaries dissolve. It starts with a reasonable shortcut and becomes the norm.

Warning signs:

  • A module imports another module’s infra folder.
  • Two modules write to the same table.
  • A utils or shared package grows into a second application.
  • Changing one module’s schema breaks another module’s build.
  • The dependency graph has a cycle, usually introduced by a single “just this once” import.

The prevention is the same discipline the rest of the architecture depends on: lint rules that fail the build, code owners per module, a small public interface, and a review culture that treats an internal import as a bug. Boundaries are cheap to keep and expensive to restore, so enforce them from the first module.

Events inside one process

In-process events decouple modules without a broker. A small dispatcher receives a published fact and calls the subscribed handlers, all within the same process and, when you want it, the same transaction.

type DomainEvent = { type: string; occurredAt: string };
type Handler = (event: DomainEvent, tx?: Transaction) => Promise<void>;

class EventBus {
  private handlers = new Map<string, Handler[]>();

  on(type: string, handler: Handler) {
    this.handlers.set(type, [...(this.handlers.get(type) ?? []), handler]);
  }

  async publish(event: DomainEvent, tx?: Transaction) {
    for (const handler of this.handlers.get(event.type) ?? []) {
      await handler(event, tx);
    }
  }
}

Two cautions. If handlers run inside the caller’s transaction, a slow handler extends the transaction and any failure rolls back the whole use case. If they run after the commit, a crash between the two can lose the event. The outbox resolves this by writing the event to an outbox table in the same transaction, then dispatching from there.

await db.transaction(async (tx) => {
  await orders.save(order, tx);
  await tx.insert(outbox).values({
    type: "order.placed",
    payload: order.toEvent(),
  });
});

Because the event is committed with the state, it cannot be lost, and a relay can retry dispatch until every subscriber has processed it. When a module is later extracted, the relay is the only thing that changes.

A process manager for cross-module flows

When a use case spans modules and must react to failures, a process manager coordinates it explicitly instead of hiding the flow in a chain of event handlers. It listens for events, keeps its own state and issues commands.

class PlaceOrderProcess {
  async onOrderPlaced(event: OrderPlaced) {
    await this.catalog.reserve(event.orderId, event.lines);
  }

  async onReservationFailed(event: ReservationFailed) {
    await this.orders.cancel(event.orderId, "out_of_stock");
    await this.notifications.send(event.customerId, "order_cancelled");
  }

  async onReservationConfirmed(event: ReservationConfirmed) {
    await this.payments.charge(event.orderId, event.totalCents);
  }
}

The process manager is the in-process equivalent of a saga. It makes the happy path and the compensation path visible in one place, which is exactly what choreography hides. Keep the process state in a normal table so a restart resumes where it left off.

Versioning a module’s interface

A module’s index.ts is an internal API and deserves the same care as a public one. Other modules compile against it, so a careless change can break their builds.

  • Add, do not break. Add optional parameters and new methods; avoid changing an existing signature.
  • Keep the surface small. Every export is a promise. If a type does not need to leave the module, do not export it.
  • Deprecate in stages. Mark the old method, migrate callers in separate commits, then remove it. Because it is one codebase, you can search for every caller.
  • Test the contract. Module interface tests protect the promise you made to other modules.

This is cheaper than versioning a network API because there is no deploy ordering to respect, but it is the same discipline, and it is what keeps a future extraction from becoming a rewrite.

Migrations per module

Because a modular monolith has one database, it is tempting to keep one giant set of migration files. That recreates a coupling the architecture is trying to remove. Instead, let each module own its migrations, scoped to its schema or table prefix.

migrations/
  catalog/   20260901_add_product_status.sql
  orders/    20260903_add_order_confirmed_at.sql
  billing/   20260905_add_invoice_paid_at.sql

A migration that touches another module’s tables is a boundary violation in disguise and should fail review. Keeping migrations modular means the ownership rule holds all the way down to the schema, and it makes moving a module’s tables to its own database a matter of replaying one folder.

When to merge modules back

Not every boundary is right the first time. If two modules always change together, share a transaction, and call each other in both directions, they are one module with an artificial seam. Merging them back is a legitimate and healthy decision.

The signs are concrete: a pull request routinely touches both modules, their interface changes on every feature, and the dependency graph has a cycle you keep working around. Merging is cheap in a monolith — move the code, collapse the interface, update the imports — and it removes a coordination cost that would only get worse. The goal is clear boundaries, not a particular number of them.

Keeping the seams healthy

Boundaries decay quietly, so check them on a schedule rather than waiting for a rewrite. A few automated fitness functions catch the drift while it is still cheap to fix.

  • Fail CI when a module imports another module’s internals.
  • Fail CI when the dependency graph contains a cycle.
  • Fail CI when a migration references a table owned by another module.
  • Report the number of files and public exports per module as a trend; a module that keeps growing is a boundary that may be in the wrong place.
  • Require a CODEOWNERS review for changes to a module’s public interface.

None of these require new infrastructure. They are ordinary tests and lint rules, and together they turn “we agreed to keep the boundaries” into something the build enforces.

Best practices

  • Define modules by business capability, never by technical layer.
  • Give every module a single public index file and keep it small.
  • Make each module the only writer to its own tables or schema.
  • Forbid cross-module imports of internals and cycles with lint rules in CI.
  • Prefer a direct interface call when you need an answer, an event when you do not.
  • Keep the dependency graph acyclic and shallow; if two modules call each other, merge or re-cut them.
  • Use one database with a schema per module and avoid cross-module foreign keys.
  • Keep a use case inside one module so it fits in one transaction.
  • Use an outbox for cross-module consistency instead of distributed transactions.
  • Test modules through their public interface, with a few end-to-end tests at the edge.
  • Keep interfaces event-ready so a module can be extracted without a rewrite.

Common mistakes

  • Calling it a modular monolith but sharing tables and importing internals.
  • Organising folders by layer and believing the modules are real.
  • Skipping the lint rules because “everyone knows the convention”.
  • Letting a shared utilities package become a hidden coupling point.
  • Running a cross-module flow as a distributed transaction when the modules should be one.
  • Adding circular dependencies and papering over them with dynamic imports.
  • Extracting a service before the module has a clean interface or owns its data.
  • Putting business rules in controllers so modules cannot be tested in isolation.
  • Treating the style as a temporary stop and never enforcing the boundaries.
  • Assuming one deploy means one failure domain and skipping basic resilience work.

Where to go next

If a concrete pressure later justifies a split, the Microservices guide explains what you gain, what you pay and how to extract one module at a time. To organise the code inside each module, read Clean Architecture, and to decouple modules through facts rather than calls, read Event-Driven Architecture. When you need the storage patterns behind module ownership, PostgreSQL covers schemas, transactions and constraints.

In practice

Module, call, boundary, event

The four pieces that make a monolith modular instead of layered.

src/modules/catalog/index.ts
// Public surface of the catalog module.
export type { Product, ProductId, Sku } from "./domain/product.js";
export { CatalogService } from "./application/catalog-service.js";
export { onStockChanged } from "./application/stock-handlers.js";

// Private by convention and by lint rule:
// ./domain/**  entities, value objects, invariants
// ./infra/**   repositories, ORM mappings, clients

Public interface versus reaching inside

A module that imports another module's repository is coupled to its schema and its internals. A module that imports the interface can be replaced or extracted.

Prefer
import { CatalogService } from "../../catalog/index.js";

// The order module knows only what catalog promises.
const product = await catalog.getProduct(sku);
Avoid
import { ProductRepository } from "../../catalog/infra/product-repository.js";

// Now orders depends on catalog's tables and ORM mappings.
// A schema change in catalog breaks orders.
const product = await productRepository.findBySku(sku);

Modular monolith versus microservices for a small team

A small team gets the same internal boundaries with far less operational cost. Extract a service only when a concrete reason appears.

Prefer
// One process, one transaction, one deploy.
await this.orders.save(order);
await this.catalog.reserve(order.lines);

// Refactor and rename across modules in one commit.
Avoid
// Three services and a saga for the same use case.
const order = await orders.create(input);
await inventory.reserve(order.id);   // network
await payments.charge(order.id);     // network
// Any of the above can fail after the others succeeded.

Trade-offs

Why start with a modular monolith?

The style keeps most of the simplicity of a monolith while giving you the option to distribute later. The catch is that boundaries only exist if you enforce them.

Strengths

  • Transactions stay simple

    A use case that touches one module is a single ACID transaction with a real rollback. No sagas, no compensating actions, no eventual consistency to explain.

  • Refactoring is cheap

    Renaming an interface, moving a class or splitting a module is a normal commit. There is no versioned contract or migration window to negotiate.

  • Operations stay light

    One build, one deploy, one dashboard, one on-call rotation. A small team can run it without a platform group.

  • The seam is already there

    Because modules communicate through interfaces and events, extracting one later is a bounded change instead of an archaeology project.

Trade-offs

  • Discipline is the whole game

    Nothing in the runtime stops one module from importing another's repository. Without lint rules and review, the boundaries erode in weeks.

  • One deploy is one blast radius

    A memory leak or a crash in one module can take the whole application down. Modules do not fail independently the way services do.

  • Scaling is all or nothing

    If one module needs far more CPU than the rest, you scale the entire application until you extract that module.

  • Shared memory is a temptation

    In-process globals and shared caches make it easy to couple modules invisibly. Treat shared state as a boundary violation.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Modular Monolith?

Our interactive tutorial walks you through Modular Monolith step by step — with quizzes and real code you can run in the browser.