Modular Monoliths: Strong Boundaries in One Deployment

Design a modular monolith with module ownership, public contracts, and enforced dependency rules while keeping one shared deployment and runtime.

published: reading time: 11 min read author: GeekWorkBench
Quick Summary

Modular monoliths keep an application in one deployment while giving each capability an owner, public contract, and private implementation. This guide shows how to block cross-module imports and writes, handle workflows that span modules, and migrate boundaries with compatible schema changes. Use the pattern when shared runtime and release operations still fit, then consider service extraction only when independent scaling, release cadence, fault isolation, or regulation justify it.

Modular Monolith Architecture: Strong Boundaries in One Deployment

Introduction

An application can ship as one process and still have useful boundaries. The trouble starts when an order handler reaches into the catalog module’s private repository:

// Coupled: Orders knows Catalog's storage implementation.
import { catalogRepository } from "../catalog/catalogRepository";
const product = await catalogRepository.findById(productId);

Route the same request through Catalog’s public contract instead:

// Bounded: Orders depends on Catalog's supported API.
import { lookupProduct } from "../catalog";
const product = await lookupProduct(productId);

The modules still share a deployment, but Catalog can change its storage without exposing that change to Orders. This guide covers when that structure fits, how to enforce its contracts, and how to carve boundaries out of an existing application.

When to Use / When Not to Use

Use this structure when the product has distinct business capabilities but shared deployment, transactions, and local development are still useful. It fits teams that need clear ownership and want to defer the network, operations, and data consistency costs of service separation.

It is not the right choice when a module truly needs independent scaling, release cadence, fault isolation, or regulatory isolation and the team can operate that boundary. Nor does it suit code that has not yet developed meaningful domain boundaries; forcing modules too early can freeze guesses into APIs. A modular monolith is a deliberate deployment choice, not a promise that it will later become microservices.

Core concepts

Give each module an owner, a public API, and control over its data. Other modules call the public API or consume an explicitly published event; they do not reach into internal classes. Keep public contracts small and in the language of the capability, such as Orders.placeOrder, rather than exposing persistence tables.

Dependency direction needs enforcement. In a simple application, package boundaries and lint rules may suffice. In a larger TypeScript or Java system, separate packages, build modules, or architecture tests can reject imports that cross into internals. A shared database does not automatically invalidate modularity, but direct cross-module writes do: they erase ownership and make schema changes risky.

Mermaid diagram

flowchart LR
  WEB[Web application] --> ORDERS[Orders module]
  ORDERS --> CATALOG_API[Catalog public API]
  CATALOG_API --> CATALOG[Catalog module]
  ORDERS --> ORDER_DATA[(Orders-owned data)]
  CATALOG --> CATALOG_DATA[(Catalog-owned data)]
  SHIPPING[Shipping module] --> ORDERS_API[Orders public API]
  ORDERS_API --> ORDERS

Every module runs inside the same application process and release, but its internal data and implementation remain private. Calls can be ordinary in-process calls; a network hop is not needed to make a boundary real.

Implementation / code example

One useful repository shape makes the public entry point visible and keeps internals out of import paths:

src/
  modules/
    orders/
      index.ts          # exported public API
      placeOrder.ts     # internal use case
      orderRepository.ts
    catalog/
      index.ts          # lookupProduct() contract
      lookupProduct.ts
      catalogRepository.ts
// modules/catalog/index.ts
export interface ProductSnapshot {
  id: string;
  unitPriceCents: number;
  available: boolean;
}
export { lookupProduct } from "./lookupProduct";

// modules/orders/placeOrder.ts
import { lookupProduct } from "../catalog"; // public entry point only

export async function placeOrder(productId: string, quantity: number) {
  if (!Number.isInteger(quantity) || quantity < 1) {
    throw new Error("Quantity must be a positive integer");
  }
  const product = await lookupProduct(productId);
  if (!product?.available) throw new Error("Product unavailable");
  // Orders persists its own order record through its own repository.
  return { productId, quantity, totalCents: product.unitPriceCents * quantity };
}

The example leaves inventory reservation unresolved on purpose: checking availability and then writing an order can race. A real system needs a reservation contract or a durable workflow that defines which module owns stock changes. The API boundary makes that decision visible; it does not make the business operation atomic by itself.

Enforcing module boundaries

Make the public entry point the only supported import path. Keep internal files out of package exports, then add an architecture test or import-lint rule that rejects imports such as ../catalog/catalogRepository from another module. Run that check in CI so a shortcut cannot merge unnoticed. In code review, treat changes to a module’s public API like changes to any other interface: callers should not need to know which tables or classes implement it.

The right enforcement depends on the repository. A small codebase can use a short architecture test; a larger one can use package boundaries or a dependency rule tool such as dependency-cruiser. Start by blocking cross-module access to internals. Add stricter rules for shared utilities or cycles only when those risks appear. If modules share a database server, separate schemas or database roles can reinforce ownership, but application rules still need to prohibit one module from writing another module’s records.

Migrating an existing application

Extract one capability at a time. First map its callers and data writes, then define the public operations it needs to expose. Move implementation behind that API while keeping existing routes and user behavior in place. Once callers use the contract, move tables or schemas only if ownership is still unclear; schema movement adds risk and is not required just to create a module boundary.

Deploy schema changes with an expand-and-contract sequence: add the new shape, make the owning module write it, migrate existing records, switch readers, then remove the old shape after all callers have moved. Keep each release compatible with the previous one so a rollback does not restore code that expects a deleted column. For workflows that span modules, decide whether they need one database transaction, a reservation, or an asynchronous process before splitting the writes across APIs.

This migration keeps one release unit. A deployment still ships every module, and a bad migration or process-wide resource spike can affect the whole application. That is usually cheaper than coordinating independent services, but it means module-level ownership does not provide independent rollback or fault isolation.

Trade-off table

Choice Benefit Cost
One deployment Simple local runs, release, and in-process calls All modules share runtime and release risk
Module-owned data Schema changes have a clear owner Cross-module reads require explicit contracts or projections
Public APIs only Refactoring internals is safer API design and mapping take deliberate effort
Shared process No network call for every module interaction A crash or resource exhaustion can affect all modules

Production failure scenarios + mitigations

A team bypasses an API and queries another module’s table. The shortcut becomes an undocumented contract. Restrict database access where practical, add import/schema checks, and replace the query with a supported API or read model.

A synchronous module call creates a long chain. Latency and failure propagate across capabilities. Measure call depth, set timeouts where external I/O is involved, and consider an event or explicit workflow for naturally asynchronous work.

A migration breaks another module. This usually reveals shared ownership. Coordinate expand-and-contract schema changes, test module contracts, and move access behind the owning module before changing the schema.

A cross-module workflow updates one capability but not the next. For example, an order may be recorded while inventory reservation fails. Decide whether the operation needs one local transaction or a durable workflow with retry and compensation; do not hide partial success behind a method that looks atomic.

Observability checklist

  • Add module and operation names to traces while preserving one request trace across in-process calls.
  • Track latency and error rates by public module operation.
  • Monitor database pool pressure and slow queries, which affect the whole process.
  • Record deployment version and migration outcome for incident diagnosis.
  • Audit asynchronous events with event IDs, consumer outcomes, and retry/dead-letter counts.

Security/compliance notes

Module boundaries are not automatically security boundaries: code in one process may share credentials and memory. Apply authorization at the use case, restrict database roles or schemas when useful, and avoid passing unnecessary personal data across module APIs. Keep audit events owned and retained according to the relevant business process. If policy requires process-level isolation, a modular monolith alone cannot satisfy it.

Common pitfalls / anti-patterns

  • Using module names as folders while allowing unrestricted imports.
  • Treating one shared common package as a place for every model and helper.
  • Letting multiple modules write the same tables.
  • Publishing internal persistence entities as stable module contracts.
  • Extracting services merely because a module exists, before independent operations justify the cost.
  • Assuming one process means module failures cannot affect other capabilities.

Quick Recap Checklist

  • Does each module have an owner and a narrow public API?
  • Are private imports and cross-module writes blocked or detectable?
  • Does each module own its data changes?
  • Are cross-module workflows and race conditions explicit?
  • Is one deployment still the right operational boundary?

Interview Questions

1. How does a modular monolith differ from a microservices system?

A modular monolith keeps modules in one process and deployment, usually communicating through in-process contracts. Microservices add independent deployment and network boundaries, along with distributed operations and consistency concerns.

2. What makes a module boundary real?

Clear ownership, a public contract, private internals, and dependency rules that are checked by the build or review process. Naming a folder is not enforcement.

3. Can modular monoliths share a database?

They can share one database server, but modules should own their tables or schemas and avoid direct writes into one another's data. A shared database must not become an excuse for hidden coupling.

4. How can a team stop imports from reaching another module's internals?

Expose a module entry point, then enforce allowed imports with an architecture test, package exports, or an import-lint rule in CI. The check should reject private paths while allowing callers to use the public contract.

5. Does every module need its own database or schema?

No. A module needs clear ownership of its data changes. Separate schemas or database roles can strengthen that rule, but a shared database can work when other modules access the data through an owner-approved contract.

6. What should happen when a workflow spans two modules?

Choose the consistency model explicitly. Use one local transaction when both writes share the same transactional boundary; use a reservation or durable workflow with retries and compensation when partial completion is possible.

7. How should a team extract a module from a legacy codebase?

Start with one capability, map its callers and data writes, define its public operations, and route callers through that contract. Move data only when ownership requires it; changing code boundaries does not require an immediate database migration.

8. Why use expand-and-contract for a module-owned schema change?

It lets old and new application versions work during deployment. Add the new shape, migrate writes and readers, then remove the old shape only after callers have moved, so rollback does not restore code that depends on deleted data.

9. Can one module be deployed or rolled back independently?

No. A modular monolith has one release unit. Module boundaries improve code ownership and change safety, while independent deployment and rollback require separate deployable services.

10. When is it useful to extract a module into a service?

Consider extraction when a capability needs independent scaling, release timing, fault isolation, or regulatory controls and the team can operate the new boundary. A module folder alone is not evidence that a service is needed.

Further Reading

Conclusion

A modular monolith combines one deployment with meaningful internal ownership. It is a good fit when teams need structure but do not yet need the network and operational costs of separate services. Make the contracts visible, enforce dependency rules, and treat data ownership as part of the boundary. That gives the codebase room to change without claiming that deployment topology has solved every design problem.

Category

Related Posts

Clean and Onion Architecture: Keeping Policy at the Center

Compare clean and onion architecture, learn how concentric boundaries keep policy independent, and apply dependency rules with practical code and trade-offs.

#software architecture #clean architecture #onion architecture

Hexagonal Architecture: Ports, Adapters, and Testable Cores

Learn hexagonal architecture through ports, adapters, and inward dependencies, with practical code, trade-offs, failure modes, and security guidance.

#software architecture #hexagonal architecture #ports and adapters

Layered Architecture: A Guide to Responsibility Tiers

Learn layered architecture's responsibility tiers, dependency rules, code examples, and trade-offs for separating presentation, domain, and infrastructure.

#software architecture #layered architecture #maintainability