Architectural Styles Deep Dive · 2 of 8

Modular Monolith — Boundaries Without Networks

A modular monolith ships as one deployable, but inside it has hard internal walls — explicit modules, defined interfaces, no cross-imports. You get monolith simplicity with the option to extract any module into its own service later, without rewriting it. For most companies, most of the time, this is the right default.

Bounded ContextsInternal APIsOne DeployPragmatic
← Back to Architecture
Quick Facts

What Makes It "Modular"

Basic Concepts

  • One deploy, many modules: still a single binary, but built from several internal modules with explicit boundaries.
  • Modules talk through interfaces. No reaching into another module's internals — same discipline as microservices, in-process.
  • Each module owns its data. Even with a shared database, each module owns its tables; no cross-module SQL joins.
  • Boundaries are enforced by tooling, not just policy. CI fails when a module imports another module's internals.
  • Future-proof: when one module needs to scale separately or be owned by a separate team, you extract it as a service. The internal API becomes an HTTP/gRPC contract.
Why It Wins

The Best of Both Worlds

Microservices Without the Tax

You get clear bounded contexts, team ownership, swappable internals — and skip the service mesh, distributed tracing, schema registry, contract tests, and on-call rotations per service. The complexity stays inside one process where it's cheap.

Refactoring Across Modules Stays Cheap

Need to move a concept from one module to another? It's a code refactor in one repo, atomic in one PR. The same change across microservices is a multi-team, multi-deploy migration that takes weeks.

Extract Only When Pain Justifies It

If a module never needs separate scaling or independent deploys, it stays in. You've paid no premium for keeping it. The architecture lets you defer the "should this be a service?" question until you have evidence — instead of guessing on day one.

Mechanics

How to Enforce Boundaries

Package & Module Structure

Organize the codebase by bounded context, not by technical layer. Top-level folders are billing/, orders/, inventory/ — each containing its own controllers, services, repositories, and domain.

Anti-pattern: top-level controllers/, services/, repositories/. That's a layered monolith — modules end up smeared across all three.

Public APIs Per Module

Each module exposes a small public surface — a few interfaces, a few DTOs. Everything else is internal. Other modules call only the public surface; they have no idea what's behind it.

  • Java: public class in a .api package; the rest in .internal.
  • Go: put internals in internal/ — the compiler refuses cross-module imports.
  • JavaScript / TypeScript: a single index.ts per module exporting the public API; ESLint no-restricted-imports for the rest.
  • .NET: separate projects per module; only public types reachable across project references.
Enforce in CI

Without automation, modules drift back into a ball of mud within months. Tools that fail builds on boundary violations:

  • ArchUnit (Java/Kotlin) — write architecture rules as JUnit tests.
  • dependency-cruiser, Nx, Turborepo (JS/TS) — declare allowed dependencies; CI enforces.
  • NetArchTest (.NET) — ArchUnit equivalent.
  • import-linter (Python).
  • Go's internal/ — built into the compiler.
Database Discipline

Same database, but each module owns its tables. No module reads or writes another module's tables directly. To get billing data, the orders module calls BillingFacade, not SELECT * FROM invoices.

This is the rule that pays off most when extracting. Modules with shared SQL joins are nearly impossible to split; modules that already talk via APIs are a one-week extraction.

Internal Events

Modules can publish events on an in-process bus (Spring's ApplicationEventPublisher, MediatR, simple pub/sub). Consumers subscribe without coupling to the producer. When you extract a module later, the in-process bus becomes Kafka or RabbitMQ — the producer/consumer code barely changes.

Where It Hurts

The Discipline Tax

It Requires Constant Vigilance

Without CI enforcement, the modules will quietly merge. A "quick fix" that imports across boundaries to ship a feature is the first crack; six months later, it's a regular monolith again. The architecture only works if the rules are mechanical.

Up-Front Modeling Cost

Drawing module boundaries requires understanding the domain. Get it wrong and modules talk to each other constantly through awkward APIs. Bounded-context modeling (DDD) helps but takes time and senior judgment.

Doesn't Solve Operational Scaling

Still one process, still scales as a unit. If one module has a fundamentally different load profile — say a CPU-heavy ML inference module versus a CRUD admin module — you'll eventually need to extract it.

Decision

When to Pick a Modular Monolith

Default to this when:

  • You have multiple teams but not 20+ teams.
  • The domain has clear-ish bounded contexts but you're not 100% sure where they belong.
  • You want microservice-ready code without paying microservice operational cost yet.
  • Your scaling needs are met by horizontal replicas of one app.

When a module's scaling profile, team ownership, or release cadence diverges sharply from the rest, extract that one module. Do not extract everything; do not preemptively split based on speculation. Distillation, not big-bang.

Continue

Other Architectural Styles