Chapter 11: Modular Monolith Architecture

fsa architecture-styles modular-monolith

Status: Notes complete


Overview

The Modular Monolith is a single-deployable-unit architecture in which internal code is divided into well-defined, independently-reasoned modules with explicit, enforced interfaces. It sits on the spectrum between the traditional layered monolith (technically-partitioned, low modularity) and microservices (distributed, high operational complexity), offering a practical middle ground: strong domain-driven separation without the overhead of a distributed system. The style is entirely new to the 2nd edition of Fundamentals of Software Architecture and reflects an industry recalibration toward monolith-first or “modular-first” thinking.


Topology

┌─────────────────────────────────────────────────────────────┐
│                  Single Deployable Unit                     │
│                                                             │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐   │
│  │   Module A   │   │   Module B   │   │   Module C   │   │
│  │  (Orders)    │   │ (Inventory)  │   │  (Shipping)  │   │
│  │              │   │              │   │              │   │
│  │ ┌──────────┐ │   │ ┌──────────┐ │   │ ┌──────────┐ │   │
│  │ │  Public  │ │   │ │  Public  │ │   │ │  Public  │ │   │
│  │ │   API    │◄├───┼─┤   API    │◄├───┼─┤   API    │ │   │
│  │ └──────────┘ │   │ └──────────┘ │   │ └──────────┘ │   │
│  │              │   │              │   │              │   │
│  │ ┌──────────┐ │   │ ┌──────────┐ │   │ ┌──────────┐ │   │
│  │ │ Internal │ │   │ │ Internal │ │   │ │ Internal │ │   │
│  │ │ (hidden) │ │   │ │ (hidden) │ │   │ │ (hidden) │ │   │
│  │ └──────────┘ │   │ └──────────┘ │   │ └──────────┘ │   │
│  └──────┬───────┘   └──────┬───────┘   └──────┬───────┘   │
│         │                  │                  │           │
│  ┌──────▼──────────────────▼──────────────────▼───────┐   │
│  │                  Database                           │   │
│  │   [Schema A]        [Schema B]       [Schema C]     │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

The topology is a single deployable artifact (JAR, EXE, container image) that contains multiple discrete modules. Each module owns a well-defined public API — the only surface area visible to other modules — while its internal packages, classes, and implementation details are fully encapsulated. Modules map to business domains rather than technical layers. A typical application has 3–10 modules; very large codebases may reach 15–20, though at that scale the migration to microservices often becomes cost-justified.


Style Specifics

Monolithic Structure

All modules compile into and deploy as a single unit. There are no network calls between modules — all inter-module communication happens in-process (direct method calls or in-process messaging). This eliminates the eight fallacies of distributed computing: no network latency, no partial failure, no serialization overhead, no service discovery complexity. A single deployment pipeline, single runtime, and single process simplify operations dramatically compared to microservices.

Modular Structure

The defining characteristic is enforced module boundaries. Each module exposes a narrow, explicit public API. Internal packages are inaccessible from other modules — enforced either by language-level module systems (Java Platform Module System, C# internal access modifiers, Python __all__) or by static analysis fitness functions (ArchUnit, NetArchTest). Modules map to bounded contexts in Domain-Driven Design: Orders, Inventory, Shipping, Billing, etc. This is domain partitioning rather than technical partitioning.

The contrast with a layered monolith is stark: in a layered monolith, any component can directly access any other component in the same layer or a lower layer. In a modular monolith, access to another module’s internals is a hard violation caught at build time.

Module Communication

Modules communicate through three mechanisms, in order of preference:

  1. Direct method calls via the public API: The most common pattern. Module A calls a public method on Module B’s API facade. Fast, synchronous, type-safe. No serialization. This is the default.

  2. In-process message passing: One module publishes an event to an in-process event bus (e.g., Guava EventBus, MediatR, Spring’s ApplicationEventPublisher). Other modules subscribe. Decouples publisher from subscriber. Still zero network overhead. Used when you want loose coupling or when the interaction is fire-and-forget.

  3. Explicit API contracts: Modules define interface contracts (DTOs, interfaces, or records) in a shared “contracts” package accessible to all modules. This prevents tight coupling to internal types and makes future extraction to microservices easier — the contract becomes the service interface.

Modules must never share mutable state directly. There are no direct references from one module’s internal objects to another module’s internal objects.


Data Topologies

Each module owns its data exclusively. The two variants:

Separate Schemas (same database server)

┌─────────────────────────────────────────┐
│         Physical Database Server        │
│  ┌────────────┐  ┌────────────────────┐ │
│  │ Schema:    │  │ Schema:            │ │
│  │  orders    │  │  inventory         │ │
│  │            │  │                    │ │
│  │ orders     │  │ products           │ │
│  │ order_items│  │ stock_levels       │ │
│  └────────────┘  └────────────────────┘ │
│  ┌──────────────────────────────────┐   │
│  │ Schema: shipping                 │   │
│  │ shipments  │  addresses          │   │
│  └──────────────────────────────────┘   │
└─────────────────────────────────────────┘

The most common variant. One physical database server, but each module gets its own schema. No cross-schema joins in application code (enforced by fitness functions). If Module A needs data from Module B’s domain, it calls Module B’s API — not a join on Module B’s tables.

Separate Databases (full isolation)

Each module has its own database instance. Strongest isolation, highest operational overhead. Typically unnecessary for a modular monolith; this topology is more characteristic of microservices. Use only when modules have genuinely different storage needs (e.g., one module needs a graph database, another needs a document store).

Handling Cross-Module Queries

Cross-module queries (e.g., “show me order summary with product names and shipping status”) require a deliberate strategy:

  • API Composition: Call each module’s API, assemble in the caller.
  • Read-model projection: A dedicated reporting module subscribes to domain events from all modules and maintains a denormalized read table.
  • Shared read schema (carefully): A narrow, read-only schema that aggregates data across modules for reporting only. Write path remains module-owned. Never use this for transactional data.

The rule: no module may write to another module’s schema. Read-only access to denormalized views is an acceptable trade-off in practice.


Cloud Considerations

The single-unit nature of a modular monolith maps cleanly to container-based cloud deployments:

  • Containerization: The monolith runs as a single Docker container. Simple Kubernetes deployment — one Deployment resource, one Service. No service mesh required.
  • Horizontal scaling: The entire application scales out together. This is efficient if all modules have similar load patterns. If one module (e.g., “Image Processing”) requires 10x more CPU than others, you cannot scale it independently — this is a key limitation.
  • Stateless deployment: If the application is stateless (sessions in a distributed cache like Redis or in JWT tokens), multiple instances can run behind a load balancer with no issue.
  • Managed services: Because modules are in-process, you cannot use cloud-native per-service tooling (e.g., separate Lambda functions per module, separate Azure Container Apps). The whole monolith is one unit from the cloud provider’s perspective.
  • Databases: Use a single managed database service (RDS, Cloud SQL, Azure SQL) with multiple schemas. Connection pooling is simple — one pool for the whole application.
  • CI/CD: Single pipeline. Any change triggers a full build and deployment. This is a trade-off: simpler pipeline but slower feedback when the codebase is large.

Common Risks

Module Boundary Erosion: The most dangerous risk. Over time, under delivery pressure, developers start calling internal classes directly instead of going through the public API. Without fitness function enforcement, module boundaries gradually collapse, and the modular monolith degrades into a big ball of mud. Mitigation: automated dependency checks in the CI pipeline (ArchUnit, NetArchTest) that fail the build when internal packages are accessed from outside the module.

Data Sharing Creep: Modules begin sharing database tables — first “just for a join,” then for writes. Once tables are shared, module boundaries are effectively broken at the data layer. Mitigation: enforce schema ownership with database-level permissions (each module’s service account has write access only to its own schema) and fitness functions that check for cross-schema writes.

Difficulty Extracting to Microservices: If module boundaries were not strictly maintained, extracting a module into an independent microservice later requires untangling hidden coupling in both code and data. The modular monolith’s value as a stepping stone to microservices is entirely contingent on maintaining strict boundaries throughout its lifetime. Mitigation: treat the module’s public API contract as if it were a network API. Design it for replaceability from the start.

Single Point of Deployment Failure: A defect in any module requires a full redeployment of the entire application. There is no per-module independent deployment. This is acceptable for smaller teams but becomes a friction point as the team grows.

Scalability Ceiling: Because the entire application is a single process, you cannot scale hot modules independently. At high scale, this forces either a full-application scale-out (inefficient) or an extraction of high-load modules into services.


Governance

Governance in the modular monolith is primarily about preventing boundary violations at build time.

Dependency fitness functions (ArchUnit — Java):

@Test
void modules_should_not_access_internal_packages() {
    noClasses()
        .that().resideInAPackage("com.app.orders..")
        .should().accessClassesThat()
        .resideInAPackage("com.app.inventory.internal..")
        .check(importedClasses);
}

NetArchTest (.NET):

Types.InAssembly(assembly)
    .That().ResideInNamespace("App.Orders")
    .ShouldNot()
    .HaveDependencyOn("App.Inventory.Internal")
    .GetResult().IsSuccessful;

Key governance rules to enforce:

  1. No direct dependency from Module X to Module Y’s internal packages.
  2. No cross-module database schema writes (checked via schema-level DB permissions).
  3. All inter-module communication goes through the public API or the in-process event bus.
  4. No shared mutable static state between modules.
  5. Module API contracts use only types from the shared contracts package or primitive types.

These rules should live in the CI pipeline and fail the build — not just warnings. Architecture violations that don’t break the build are rarely fixed.


Team Topology

The modular monolith maps naturally to stream-aligned teams in the Team Topologies model, with one team owning one or more modules:

┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐
│  Orders Team     │  │ Inventory Team   │  │  Shipping Team   │
│  owns:           │  │  owns:           │  │  owns:           │
│  - Module: Orders│  │  - Module: Inv.  │  │  - Module: Ship. │
│  - Schema: orders│  │  - Schema: inv.  │  │  - Schema: ship. │
└──────────────────┘  └──────────────────┘  └──────────────────┘
         │                     │                      │
         └─────────────────────┴──────────────────────┘
                   Shared: deployment pipeline,
                   contracts package, fitness functions

Conway’s Law implication: Teams own modules, not layers. This avoids the classic layered-architecture problem where the database team, the API team, and the UI team must all coordinate for every feature. Instead, the Orders team can add a new order type end-to-end within their module without coordinating with other teams — as long as the public API contract remains stable.

Coordination overhead is much lower than microservices (no cross-team service contracts, SLA negotiations, or distributed tracing setup) but higher than a layered monolith (module API contracts must be maintained). This is an intentional trade-off: a small increase in design discipline yields a large reduction in operational overhead.

Typical team size: 2–5 engineers per module. Typical org size where this style works: 5–50 engineers. Below 5, a simple layered monolith suffices. Above 50, microservices may be warranted.


Architectural Characteristics Ratings

CharacteristicRatingNotes
Overall agility★★★☆☆Good within a module; full-app deployments limit cross-team agility
Ease of deployment★★★☆☆Single deployment unit is simple, but any change redeploys everything
Testability★★★★☆In-process; easy unit, integration, and module isolation testing
Performance★★★★★In-process calls; no serialization or network overhead between modules
Scalability★★☆☆☆Cannot scale individual modules independently; whole-app scale-out only
Ease of development★★★★☆Local development is simple (single process, no Docker Compose orchestra)
Simplicity★★★★☆Simpler than microservices; more disciplined than layered monolith
Overall cost★★★★★Lowest infrastructure cost; single runtime, single DB connection pool

When to Use

  • Teams that find microservices operationally too complex (no dedicated DevOps/SRE capacity)
  • Applications that need modular domain separation but do not require independent deployment of individual modules
  • Greenfield projects planning a future migration to microservices — use this as the first step
  • Applications where all modules have similar scalability requirements (no single module wildly outpacing others)
  • Organizations with 5–50 engineers that want clear team ownership boundaries without distributed system overhead
  • Systems where performance is critical and network-call latency between services is unacceptable
  • Teams that have a layered monolith and want to refactor toward modularity without a full microservices rewrite

When Not to Use

  • Individual modules need to be deployed independently on different cadences (e.g., the pricing module needs daily deployments, the authentication module needs weekly)
  • Very high and uneven scale requirements: one module receives 1000x the traffic of others (forces an extraction to services anyway)
  • Different modules require fundamentally different runtime environments or technology stacks
  • Organization has full microservices infrastructure already in place and teams are experienced with distributed systems
  • The domain has extremely clear, stable, non-overlapping bounded contexts with dedicated teams — microservices may be appropriate

Examples and Use Cases

E-commerce platform (mid-size): Orders, Inventory, Catalog, Shipping, Billing, and Notifications as modules. A team of 20 engineers can ship features independently per module while sharing a single production deployment. Migration path: if the Catalog module outgrows the monolith (high read traffic), extract it as the first microservice.

SaaS B2B application: Multi-tenant SaaS with modules for Authentication, Tenant Management, Billing, Core Product, and Reporting. The modular monolith handles the initial 0–100k user scale; the architecture provides clean extraction points if specific modules hit scale limits.

Internal enterprise tool: A company’s internal operational platform with modules for HR data, Finance, Inventory, and Scheduling. Low traffic but high complexity. The modular monolith gives clear team ownership without the operational burden of microservices that the internal IT team cannot sustain.


Key Takeaways

  1. Modular Monolith defined: A single deployable unit internally divided into modules with explicit, enforced interfaces — the middle ground between layered monolith and microservices.
  2. Domain partitioning: Modules map to business domains (Orders, Inventory), not technical layers (Repository, Service, Controller). This is the key structural distinction from a layered monolith.
  3. Enforced boundaries are non-negotiable: Without automated fitness functions (ArchUnit, NetArchTest), boundaries erode. If the build doesn’t fail on violations, the violations will accumulate.
  4. No cross-module database access: Each module owns its schema. Cross-module data needs are satisfied via the public module API or a dedicated read projection, never via SQL joins across schemas.
  5. In-process communication is a superpower: Calling another module’s API is a method call — zero latency, zero serialization, full type safety. This is the primary performance advantage over microservices.
  6. Stepping stone value: A well-maintained modular monolith with clean API contracts can be incrementally extracted to microservices — one module at a time — when the business case justifies it.
  7. Scalability ceiling: The inability to scale individual modules independently is the definitive architectural limitation. This is the primary driver for eventual migration to services.
  8. Team topology alignment: One team per module (or per 2-3 modules) maps clearly to stream-aligned teams in Team Topologies, reducing coordination overhead significantly versus microservices.
  9. Data sharing creep is the most insidious failure mode: Schema sharing seems harmless at first. By the time it’s a problem, it’s deeply embedded and expensive to fix.
  10. Cost advantage is real: One process, one database connection pool, one deployment pipeline, one monitoring target. Infrastructure cost and operational complexity are the lowest of any non-trivial architecture style.

Last Updated: 2026-05-29