What is Modular Monolith ?
A modular monolith is a single deployable application that is internally organised into clearly separated, independent modules. It gives you the simplicity of a monolith and the discipline of microservices — without paying the network tax on day one. This guide walks through the history, the internals, the trade-offs, and the real production patterns, explained from first principles.
1. Introduction & History
Imagine you are building a house. You could build it as one giant room with no walls — a kitchen counter next to your bed, next to the shower, next to the study table. Everything is in the same space, so it is fast to build, but chaotic to live in. Or, you could build many tiny separate houses, one for the kitchen, one for the bedroom, one for the bathroom, each with its own walls, its own front door, and its own electricity connection. That is flexible, but now you need roads, gates, and a lot of walking between houses just to cook breakfast and go to sleep.
A modular monolith is the middle path: one house, but with proper rooms, proper walls, and proper doors between them. It is still a single building — one foundation, one roof, one address — but inside, every room has a clear purpose and a controlled entrance. You cannot walk from the bedroom into the kitchen by breaking through a wall; you must use the door.
In software terms, a modular monolith is a single application, built and deployed as one unit, but internally split into well-defined modules with explicit boundaries and controlled interactions. Each module owns its own code, its own data structures, and often its own slice of the database, but all modules run inside the same process and are shipped together as one artifact — one JAR, one WAR, one container image.
Where did this idea come from?
To understand why the modular monolith exists, it helps to look at the timeline of how backend architecture evolved over the last three decades.
- 1990s — Big Ball of Mud era: Early enterprise applications were monoliths, but rarely organised ones. Code was added wherever it was convenient, and dependencies grew in every direction. This unplanned, tangled style is famously nicknamed the “Big Ball of Mud.”
- Early 2000s — Layered enterprise architecture: Patterns like Model-View-Controller (MVC) and n-tier architecture (presentation, business logic, data access) tried to bring order by slicing an application horizontally, by technical concern rather than by business meaning.
- 2011–2015 — The microservices wave: Companies like Netflix, Amazon, and later Uber popularised splitting large systems into many small, independently deployable services, each with its own database and its own deployment pipeline. This solved many scaling and team-autonomy problems, but introduced new ones: network calls, distributed transactions, versioning nightmares, and steep operational overhead.
- 2015 onward — The correction: As many teams adopted microservices too early, without the operational maturity to support them, well-known engineers began writing about “microservice envy” and the cost of premature distribution. Simon Brown, a respected voice in software architecture, popularised the term “modular monolith” as a deliberate architectural style, not just as “the thing you build before you know better.”
- 2020s — Mainstream recognition: Today, the modular monolith is treated as a first-class architecture, with tooling support in frameworks like Spring Modulith (Java), Wolverine and Modular Monolith with DDD templates (.NET), and NestJS module systems (Node.js). Many companies, including some who tried microservices and rolled parts of them back, now start new systems as modular monoliths by default.
Think of a school. One school building (one deployment), but separate classrooms for Math, Science, and Art (separate modules). Each classroom has its own teacher, its own equipment, and its own timetable. Students and information move between classrooms only through doors and hallways (defined interfaces) — nobody smashes through a wall to borrow a science beaker during a math class. Yet it is still one school, one address, one principal, one report card system.
By the end of this guide, you will understand not just what a modular monolith is, but how to design one, how it behaves internally, how to scale it, secure it, monitor it, deploy it, and how it compares to both the tangled “big ball of mud” monolith and to microservices — with practical Java examples along the way.
2. Problem & Motivation
To appreciate why modular monoliths exist, you need to understand the two problems on either side of them: the problem with messy monoliths, and the problem with jumping straight into microservices.
Problem 1: The tangled monolith (“Big Ball of Mud”)
When a team builds an application quickly, under deadline pressure, without enforcing boundaries, the codebase tends to grow organically in every direction. A function in the “billing” area might directly call a database table owned by “inventory.” A class in “user profile” might silently depend on internal details of “notifications.” Over months and years, this creates a web of hidden dependencies.
In a tangled monolith, changing one small thing — say, renaming a field in the Orders table — can break code in five unrelated areas that nobody remembers depend on it. Teams become afraid to change anything. New developers take months to become productive because there is no map of “what depends on what.” This is called high coupling and low cohesion, and it is the single biggest reason large codebases become unmaintainable.
Problem 2: Jumping to microservices too early
Seeing the pain of tangled monoliths, many teams concluded that the fix was to break the application into many small, independently deployed services communicating over the network. This can work extremely well — for companies with many independent teams, mature DevOps practices, and genuinely independent scaling needs. But for a small or mid-sized team, splitting too early introduces heavy costs:
- Network calls replace function calls. What used to be an instant, reliable, in-memory method call now becomes a network request that can be slow, can time out, or can fail entirely.
- Distributed transactions become hard. Updating “Order” and “Inventory” together used to be one database transaction. Across two services, you now need patterns like Sagas, which add real complexity.
- Operational overhead multiplies. Every service needs its own deployment pipeline, its own monitoring dashboards, its own on-call runbook, its own versioning strategy.
- Debugging becomes a detective story. A single user request might touch six services. Tracing what actually happened requires distributed tracing tools, correlation IDs, and careful log aggregation.
- Premature boundaries are often wrong boundaries. Early in a product’s life, you frequently do not yet know the correct way to split your business into independent services. Splitting too early bakes in bad boundaries that are painful to undo, because now the boundary is enforced by a network hop, not just a package.
Segment, a well-known data infrastructure company, published a widely-read engineering account of moving from around 140 microservices back to a single, well-organised monolith. Their conclusion was not that microservices are bad, but that their team’s size and problem did not yet justify the distributed complexity — a modular structure inside one service gave them the same clarity with far less operational burden.
The motivation for the modular monolith
The modular monolith directly targets both problems at once. It borrows the internal discipline of microservices — clear boundaries, one team or module per business capability, explicit interfaces — while keeping the deployment simplicity of a monolith: one process, one database connection pool (or clearly partitioned schemas), one deployment pipeline, and in-memory function calls instead of network calls between modules.
It gives you a system that is easy to reason about today, and — because the boundaries are already explicit in code — relatively straightforward to split into real microservices later, if and when you actually need to.
3. Core Concepts
Before going further, let’s build a solid vocabulary. Every term below is something you will see repeatedly in modular monolith discussions, interviews, and production codebases.
3.1 Module
What it is: A module is a self-contained unit of code that represents one business capability — for example, “Orders,” “Payments,” “Inventory,” or “Notifications.” It groups together everything needed to fulfil that capability: its own classes, its own internal data model, and its own business logic.
Why it exists: Without modules, a large codebase becomes one undifferentiated mass of files where nothing signals what belongs together.
Where it’s used: Java packages (e.g. com.shop.orders), .NET projects/namespaces, or folders in a Node.js repository, often enforced by build tooling.
A module is like a department inside a company — the Finance department, the HR department, the Sales department. Each has its own staff, its own filing cabinets, and its own responsibilities, but they all work under the same company roof.
3.2 Module Boundary
What it is: The invisible (but enforced) line that separates what is “inside” a module from what is “outside” it. Code outside the module is not allowed to reach into its internals directly.
Why it exists: Boundaries are what prevent the “Big Ball of Mud.” If any class can call any other class directly, no matter how far apart, the codebase quickly turns into a spider’s web.
Practical example: The payments module cannot directly query the orders module’s database table. It must go through a published interface or event.
3.3 Public API / Module Interface
What it is: The small, explicit set of classes, interfaces, or methods that a module exposes for other modules to use. Everything else inside the module is private.
Why it exists: This is the “door” in our house analogy. It lets modules cooperate without letting them depend on each other’s internal implementation details, so a module’s internals can change freely as long as its public interface stays stable.
3.4 Bounded Context
What it is: A term from Domain-Driven Design (DDD). A bounded context is the boundary within which a particular business term has one specific, unambiguous meaning.
Why it matters here: A “Customer” in the Billing module might mean “someone with a valid payment method and an invoice history.” A “Customer” in the Support module might mean “someone with open tickets and a satisfaction score.” These are different models of the same real-world concept, and trying to force them into a single shared “Customer” class is a common source of monolithic tangling. Modular monoliths typically map one module to one (or a small number of) bounded contexts.
3.5 High Cohesion, Low Coupling
Cohesion means: things that change together, live together. A module is highly cohesive when everything inside it is closely related to a single business purpose.
Coupling means: how much one piece of code depends on the internal details of another. Modules should be loosely coupled — able to change internally without breaking other modules — while remaining highly cohesive internally.
High cohesion = a well-organised toolbox where all the screwdrivers are together, all the hammers are together. Low coupling = you can replace your hammer with a new brand without needing to also replace your screwdrivers.
3.6 Shared Kernel
What it is: A small set of code (data types, utility classes, common value objects) that genuinely needs to be shared across multiple modules, such as a Money class or a UserId type.
Why it exists: Some concepts truly are common. The danger is teams overusing the shared kernel as an excuse to avoid proper module boundaries — so mature teams keep it deliberately small.
3.7 Deployment Unit
What it is: The single artifact (JAR file, container image, executable) that contains all the modules and gets deployed as one unit. In a modular monolith, there is exactly one deployment unit even though there are many modules inside it.
3.8 Vertical Slice vs Horizontal Layer
A horizontal layer organises code by technical role: all controllers together, all services together, all repositories together. A vertical slice organises code by business capability: everything related to “Orders” together, regardless of whether it’s a controller, service, or repository. Modular monoliths favour vertical slicing at the module level, often with horizontal layering used only inside each module.
4. Architecture & Components
Let’s zoom out and look at the shape of a typical modular monolith from the outside in.
Key structural components
| Component | Role |
|---|---|
| API / Presentation Layer | Receives HTTP requests, translates them into calls on module public interfaces. Usually thin — no business logic here. |
| Module (Domain + Application logic) | Owns one business capability end to end: validation, business rules, orchestration, and its own persistence. |
| Public Interface / Facade | The only door into a module from the outside. Everything else in the module is package-private or internal. |
| Internal Data Model | Entities, value objects, and persistence details that only the owning module understands. |
| Event Bus (in-process) | An internal publish-subscribe mechanism that lets modules react to what happened elsewhere without directly calling each other. |
| Shared Kernel | A small, deliberately limited set of cross-cutting types (money, identifiers, common exceptions). |
| Database (single instance, module-partitioned) | Usually one physical database, but with one schema (or clearly namespaced tables) per module, so no module directly touches another’s tables. |
How modules talk to each other: two safe patterns
Pattern A: Synchronous calls through a public interface. When Module A needs an immediate answer from Module B (e.g. “is this product in stock right now?”), it calls a well-defined interface method exposed by Module B — never Module B’s internal repository or entity classes directly.
// Inventory module exposes only this interface publicly
public interface InventoryFacade {
boolean isInStock(String productId, int quantity);
void reserveStock(String productId, int quantity);
}
// Orders module depends only on the interface, never on Inventory internals
public class PlaceOrderService {
private final InventoryFacade inventory; // injected
public PlaceOrderService(InventoryFacade inventory) {
this.inventory = inventory;
}
public OrderResult placeOrder(OrderRequest request) {
if (!inventory.isInStock(request.getProductId(), request.getQuantity())) {
return OrderResult.rejected("Insufficient stock");
}
inventory.reserveStock(request.getProductId(), request.getQuantity());
// ... create and persist the order using Orders' own repository
return OrderResult.accepted();
}
}
Pattern B: Asynchronous events for “fire and inform.” When Module A needs to inform other modules that something happened, without waiting for a reply (e.g. “an order was placed, notify whoever cares”), it publishes a domain event on an in-process event bus.
// Domain event, part of the shared kernel or Orders' public contract
public record OrderPlacedEvent(String orderId, String customerId, BigDecimal total) {}
// Inside Orders module, after saving the order:
public class PlaceOrderService {
private final ApplicationEventPublisher events;
public void placeOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
events.publishEvent(new OrderPlacedEvent(order.getId(), order.getCustomerId(), order.getTotal()));
}
}
// Inside Notifications module - reacts without Orders knowing it exists
@Component
public class OrderPlacedNotificationHandler {
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
// send confirmation email / SMS
emailService.sendOrderConfirmation(event.customerId(), event.orderId());
}
}
Pattern A is like calling the kitchen through the service window and waiting for your food — a direct, synchronous request. Pattern B is like ringing a bell that says “table 5’s order is placed” — the kitchen, the cashier, and the loyalty-points counter can all react to that bell in their own time, without the waiter having to walk to each of them individually.
5. Internal Working
Now let’s look under the hood: how a request actually flows through a modular monolith at runtime, and how module boundaries are enforced so developers cannot accidentally break them.
Enforcing boundaries in practice
A boundary that exists only “by convention” (a comment saying “please don’t call this directly”) gets violated within a few sprints under deadline pressure. Production modular monoliths enforce boundaries with tooling:
- Package-private visibility (Java): Classes not meant to be used outside the module are declared without the
publicmodifier, so they are physically invisible outside their package. - Architecture testing tools: Libraries such as ArchUnit (Java) let you write automated tests like “classes in package
orders.internalmust never be referenced outsideorders.” These tests fail the build if a boundary is crossed. - Framework-level module systems: Spring Modulith (Java/Spring) can verify module boundaries automatically and even generate architecture diagrams from your code. NestJS enforces module encapsulation through its dependency injection module system.
- Build-level separation: Some teams go further and put each module in its own build module (e.g. a separate Gradle/Maven module), so a compile error — not just a test failure — occurs if a boundary is crossed.
@AnalyzeClasses(packages = "com.shop")
public class ModuleBoundaryTest {
@ArchTest
static final ArchRule modules_should_only_use_public_interfaces =
noClasses().that().resideInAPackage("..orders.internal..")
.should().beAccessedByAnyPackage("..payments..", "..inventory..");
@ArchTest
static final ArchRule no_cyclic_dependencies_between_modules =
slices().matching("com.shop.(*)..")
.should().beFreeOfCycles();
}
Anatomy of a single request
Let’s trace what happens, step by step, when a customer places an order in an online store built as a modular monolith.
Notice something important: every call between Orders, Inventory, and Payments above is a plain, in-memory Java method call — nanoseconds to microseconds, not the tens or hundreds of milliseconds a network call to a separate microservice would cost. Yet the modules never touch each other’s tables directly. This is the core internal-working idea of a modular monolith: the discipline of separation, without the cost of the network.
Dependency direction matters
A healthy modular monolith has a clear, acyclic dependency graph between modules. If Orders depends on Inventory, Inventory should not depend back on Orders — that creates a cycle, which means the two modules can no longer be understood, tested, or eventually extracted independently.
A very common way modular monoliths quietly degrade back into a “Big Ball of Mud” is bidirectional dependency: Orders calls Inventory, and later, under time pressure, a developer makes Inventory call back into Orders to “just grab one field.” This creates a cycle. Automated architecture tests (like the ArchUnit rule above) exist specifically to catch this before it ships.
6. Data Flow & Lifecycle
Understanding how data moves — and how a module’s lifecycle unfolds from request to response — is essential for debugging and designing correctly.
The lifecycle of a module
- Bootstrapping: When the application starts, the framework (e.g. Spring Boot) scans and wires up each module: creating its beans/components, connecting its repositories to its own schema, and registering its event listeners.
- Request handling: A request arrives, is routed to the owning module’s public entry point, and that module coordinates whatever internal steps it needs — validation, business rules, calls to other modules’ public interfaces.
- Data persistence: The module writes to its own tables/schema through its own repository. No other module can bypass this.
- Event emission: If something noteworthy happened, the module publishes a domain event. Other modules that care have already registered listeners for it.
- Response composition: The API layer collects the result and returns a response to the client — still within the same request/response cycle, all in-process.
Data ownership: the golden rule
In a modular monolith, each table (or schema) has exactly one owning module. Other modules are never allowed to run SQL against a table they do not own — even though, physically, that table lives in the very same database instance. Access must always go through the owning module’s public interface.
If the Orders module needs to show “Product Name” alongside an order, it does not JOIN into the Inventory schema directly. Instead, it either (a) calls Inventory’s public interface to fetch the product name at read time, or (b) stores a denormalised copy of the product name at the time the order was placed (common in e-commerce, since a product’s name at purchase time shouldn’t silently change later). Both approaches respect the ownership boundary; a direct SQL JOIN across schemas does not.
Consistency: transactions inside a module vs across modules
Within a single module, you can safely use a normal ACID database transaction — for example, “reserve stock” and “decrement available quantity” happen together, atomically, because they touch only the Inventory module’s own tables.
Across modules, a modular monolith usually avoids trying to wrap a single database transaction around multiple modules’ tables (even though technically possible, since it’s one database, doing so re-creates tight coupling and defeats the purpose of module boundaries). Instead, most systems use one of two approaches:
- Orchestration inside the calling module: The calling module performs the steps in sequence (call Inventory, then call Payments, then save its own Order) and handles failure by compensating — e.g. releasing the reserved stock if payment fails.
- Eventual consistency via events: The calling module commits its own transaction and publishes an event; other modules react asynchronously and are designed to be safe even if they process the event slightly later. This mirrors the Saga pattern used in microservices, but running entirely in-process, so failures are far easier to reason about because everything is still one deployable, using one shared error/logging pipeline.
7. Advantages, Disadvantages & Trade-offs
Advantages
- Simple deployment: one artifact, one pipeline, one place to roll back
- Fast, reliable in-process calls between modules (no network latency)
- Easier local development — no need to run 15 services to test one feature
- Single, straightforward transaction model within a module
- Clear code organisation prevents the “Big Ball of Mud”
- Cheaper to operate — one set of infrastructure, not one per service
- Easier to refactor early, since boundaries live in code, not in network contracts
- A natural stepping stone toward microservices, if and when truly needed
Disadvantages
- Cannot scale individual modules independently (the whole app scales together)
- A bug or memory leak in one module can still affect the entire process
- Requires strong team discipline; boundaries can erode without enforcement
- A single large codebase can still slow down build and test times as it grows
- One shared technology stack — you can’t pick a different language per module
- Harder for many independent teams to deploy on totally separate schedules
- Database, while partitioned logically, is still one physical point of contention
The trade-off, visualised
Tangled Monolith
Low structure, low ops cost — fastest to start, but the hardest to maintain.
Modular Monolith
High structure, low ops cost — the sweet spot for most small-to-mid teams.
Microservices
High structure, high ops cost — worth it once teams and scale genuinely diverge.
The central trade-off to remember: a modular monolith exchanges independent, per-module scalability and deployment for dramatically simpler operations and dramatically lower distributed-systems risk. It is the right choice when your team is small-to-mid-sized, when your traffic does not yet demand scaling individual capabilities separately, and when you want to preserve the option to split out services later without having guessed the boundaries wrong up front.
8. Performance & Scalability
Why in-process calls are fast
A method call between two modules in the same process typically takes nanoseconds to low microseconds — it is just a jump in memory, possibly with some object creation. A network call to a separate microservice, even on a fast internal network, typically costs 0.5–5 milliseconds at best, and often much more once you include serialisation, deserialisation, TLS handshakes (if applicable), and queueing. For an order-placement flow that fans out to five internal capabilities, this difference alone can be the gap between a 20 ms response and a 200 ms response.
How a modular monolith scales
Because it is a single deployable, a modular monolith scales the same way a normal web application does: horizontally, by running multiple identical instances behind a load balancer, and vertically, by giving each instance more CPU/RAM. Every instance runs every module.
The trade-off is that you cannot scale, say, just the Payments module to ten instances while keeping Notifications at two — every instance carries the full weight of all modules. In practice, most applications’ modules have roughly correlated load (more orders means more payments, more inventory checks, and more notifications too), so this limitation matters less often than people assume — but for a module with a wildly different load profile (e.g. a reporting module that runs heavy nightly batch jobs), this is the point where teams typically consider extracting just that one module into its own service.
Techniques to keep a modular monolith fast at scale
- Read replicas: Route read-heavy queries to database read replicas, keeping the primary free for writes.
- Caching: Use an in-memory cache (Caffeine, Redis) for frequently read, rarely changed data such as product catalogs.
- Asynchronous processing: Push slow, non-critical work (emails, PDF generation, analytics) onto background workers or in-process async event handlers so the main request path stays fast.
- Connection pool tuning: Since all modules share the process, tune the database connection pool size carefully so one busy module cannot starve others of connections.
- Module-level performance budgets: Track and alert on latency per module, so a slowdown in one is visible before it drags down the whole request.
Shopify, one of the largest e-commerce platforms in the world, is widely known for running much of its core commerce logic as a modular monolith internally organised into “components,” while scaling horizontally to handle massive Black Friday traffic spikes across thousands of instances — demonstrating that “modular monolith” does not mean “small scale.”
9. High Availability & Reliability
High availability means the system keeps working even when parts of the underlying infrastructure fail. Reliability means it behaves correctly and consistently over time.
Achieving high availability
- Multiple instances, multiple zones: Run several instances of the modular monolith across different availability zones or data centres, so a single machine or zone failure does not take the whole system down.
- Health checks and self-healing: Load balancers and orchestrators (e.g. Kubernetes) continuously check instance health and automatically replace unhealthy instances.
- Graceful degradation inside modules: If the Notifications module’s email provider is down, that should not crash the Orders module. Wrapping cross-module calls (and calls to external systems) with timeouts, retries, and circuit breakers (e.g. via Resilience4j in Java) keeps a slow or failing dependency from cascading into a full outage.
- Database high availability: Use a managed database with automatic failover (a primary and one or more standby replicas), since the whole modular monolith depends on that one database being available.
@CircuitBreaker(name = "inventoryCheck", fallbackMethod = "assumeOutOfStock")
public boolean isInStock(String productId, int quantity) {
return inventoryRepository.checkAvailability(productId, quantity);
}
public boolean assumeOutOfStock(String productId, int quantity, Throwable t) {
log.warn("Inventory check failing, defaulting to out-of-stock for safety", t);
return false;
}
Because all modules share the same process and memory space, a severe bug in one module (an infinite loop, a memory leak, an unhandled exception that crashes the JVM) can, in the worst case, bring down every module at once — something that is architecturally impossible in a properly isolated microservices system, where one service crashing does not directly crash another. This is the single most important reliability trade-off to understand about modular monoliths, and it is why strong internal error handling, resource limits, and code review discipline matter even more here than in a distributed system.
Reliability practices specific to modular monoliths
- Bulkheading with thread pools: Give different modules, or at least distinguish critical vs. non-critical work, separate thread pools so a slow module (e.g. one calling a flaky external API) cannot exhaust all available threads and starve unrelated requests.
- Idempotent event handlers: Since in-process events can sometimes be redelivered (e.g. after a crash and restart with a persisted outbox), design event handlers to be safe to run twice.
- The Outbox Pattern: To guarantee that “save the order” and “publish OrderPlacedEvent” either both happen or neither happens, many modular monoliths write the event to an “outbox” table in the same database transaction as the business data, then a background process reliably publishes it.
10. Security
Security in a modular monolith operates at two levels: perimeter security (protecting the whole application from the outside world) and internal security (making sure modules cannot misuse each other, and that sensitive data stays properly scoped).
Perimeter security
- Authentication: Verifying who the user is, typically via tokens (JWT/OAuth2) validated once at the API layer.
- Authorisation: Verifying what the authenticated user is allowed to do — often enforced both at the API layer (coarse-grained) and inside each module (fine-grained, since only that module truly knows its own business rules).
- Input validation: Every module should validate its own inputs rather than trusting that “someone upstream already checked” — this containment habit limits the blast radius of a bug elsewhere in the same process.
- Transport security: TLS for all external traffic, and secure secrets management (e.g. a vault service) rather than hardcoded credentials.
Internal security within the monolith
Because everything shares one process, a module boundary is not automatically a security boundary in the same strong sense a network boundary between microservices is. This means:
- Module-boundary enforcement is also a security control — the same ArchUnit-style rules that keep code clean also stop a compromised or buggy dependency in one module from directly reaching into another module’s sensitive data structures.
- Principle of least privilege at the database level: Even though it’s one physical database, using separate schema-level credentials per module (where supported) limits the damage if one module has a SQL injection vulnerability.
- Careful dependency management: A vulnerable third-party library pulled in by one module runs in the same process as every other module’s data, so dependency scanning (e.g. OWASP Dependency-Check, Snyk) matters for the whole application, not per-module.
- Sensitive data minimisation across modules: When Module A calls Module B’s public interface, only pass the fields actually needed — don’t pass whole internal entities across the boundary, since that both breaks encapsulation and widens the surface for accidental data leaks (e.g. in logs).
Logging an entire domain object (like a full Customer entity, including hashed passwords or tokens) “for debugging” from inside another module’s log statements is a very common way sensitive data leaks in monoliths. Always log through explicit, minimal DTOs.
11. Monitoring, Logging & Metrics
Observability in a modular monolith is simpler than in microservices in one key way — there’s only one process, one log stream, one deployment to watch — but you still need module-level visibility, or you lose exactly the clarity that motivated building modules in the first place.
Structured logging with module context
Every log line should be tagged with which module produced it, and with a correlation/request ID that ties together everything that happened during one request, even across modules.
{
"timestamp": "2026-07-17T10:22:31Z",
"level": "INFO",
"module": "orders",
"requestId": "b7e1-4f2a-9c31",
"event": "OrderPlaced",
"orderId": "ORD-88213",
"durationMs": 42
}
Metrics to track per module
| Metric | Why it matters |
|---|---|
| Request latency (p50, p95, p99) per module entry point | Reveals which module is slow, even though they all share one process |
| Error rate per module | Catches a failing module before it impacts overall availability |
| Event queue depth (for async in-process events) | Shows if a downstream module is falling behind on processing events |
| Database connection pool usage | A shared resource; helps spot one module starving others |
| JVM/runtime metrics (heap, GC pauses, thread count) | Since all modules share the same runtime, this affects everyone |
Tools commonly used
- Metrics: Micrometer feeding Prometheus, visualised in Grafana
- Tracing: OpenTelemetry to trace a request as it moves across module boundaries (even in-process spans are useful for finding slow modules)
- Log aggregation: ELK stack (Elasticsearch, Logstash, Kibana) or a managed equivalent, since there’s just one application’s logs to centralise
- Alerting: Threshold and anomaly-based alerts per module metric, not just per whole-application metric
Frameworks like Spring Modulith can automatically publish module-level “observed” events and generate module dependency diagrams directly from real code, which teams then wire into their dashboards — turning the architecture itself into a living, monitorable artifact rather than a diagram that goes stale in a wiki.
12. Deployment & Cloud
One of the most attractive properties of a modular monolith is deployment simplicity: there is exactly one artifact to build, test, scan, and ship.
A typical CI/CD pipeline
Common deployment strategies
- Rolling deployment: New instances are brought up gradually while old ones are drained and removed, keeping the application available throughout.
- Blue-green deployment: A full second environment (“green”) is deployed and warmed up, then traffic is switched over from “blue” instantly, enabling near-zero-downtime releases and fast rollback.
- Canary deployment: A small percentage of traffic is routed to the new version first, and only ramped up once metrics look healthy — useful even for a monolith, since a bug in any one module still affects the whole app.
Cloud-native packaging
A modular monolith is usually packaged as a single container image and deployed on Kubernetes (as one Deployment with multiple replicas), on a Platform-as-a-Service (like AWS Elastic Beanstalk, Google App Engine, or Heroku-style platforms), or on traditional VMs behind a load balancer. Because there’s only one artifact, infrastructure-as-code (Terraform, CloudFormation) definitions stay comparatively simple compared to managing dozens of microservice deployments, each with its own scaling policy, service mesh entry, and network policy.
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop-modular-monolith
spec:
replicas: 4
selector:
matchLabels:
app: shop-app
template:
metadata:
labels:
app: shop-app
spec:
containers:
- name: shop-app
image: registry.example.com/shop-app:1.42.0
resources:
requests: { cpu: "500m", memory: "1Gi" }
limits: { cpu: "1", memory: "2Gi" }
readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
livenessProbe:
httpGet: { path: /health/live, port: 8080 }
Because it is one deployment unit, releasing a change to any single module means redeploying the entire application. This is simpler operationally, but it does mean release cadence is shared across teams working on different modules — a coordination point that independent microservices avoid, but that many teams gladly accept in exchange for the reduced operational burden.
13. Databases, Caching & Load Balancing
Database strategy
Most modular monoliths use one physical database with logical separation per module — either separate schemas (e.g. PostgreSQL schemas: orders.*, payments.*) or, at minimum, clearly namespaced tables with strict ownership rules enforced by code review and repository-layer boundaries.
| Approach | Description | When to use |
|---|---|---|
| Shared schema, shared tables | All modules use the same schema; ownership enforced only by convention | Not recommended beyond a prototype — boundaries erode fast |
| Shared database, separate schemas | One physical database instance, one schema per module, cross-schema access forbidden | The most common, well-balanced choice for modular monoliths |
| Separate databases per module | Each module has its own physical database, still one deployable application | When you want maximum future flexibility to split into microservices later |
Caching
Caching in a modular monolith can happen at multiple levels:
- In-process cache (e.g. Caffeine): Extremely fast, since it lives in the same memory space as the application — ideal for data that’s read very often and changes rarely (e.g. product categories).
- Distributed cache (e.g. Redis): Needed once you run multiple instances of the modular monolith, so all instances see the same cached value and cache invalidation is consistent.
- Module-scoped caching: Each module manages and invalidates its own cache entries — a module should never reach into another module’s cache namespace directly, for the same boundary reasons as the database.
Load balancing
Since every instance of a modular monolith is identical (it carries every module), load balancing is straightforward: a standard Layer 7 load balancer (NGINX, AWS ALB, or a Kubernetes Service) distributes incoming HTTP requests across all healthy instances, usually using round-robin or least-connections strategies, combined with health checks to automatically pull unhealthy instances out of rotation.
14. APIs & Microservices: How Do They Compare?
Let’s directly compare a modular monolith to microservices, since this is one of the most common points of confusion — and a very common interview topic.
| Aspect | Modular Monolith | Microservices |
|---|---|---|
| Deployment | One artifact, deployed together | Many artifacts, deployed independently |
| Inter-module communication | In-memory method calls / in-process events | Network calls (REST, gRPC, message queues) |
| Data ownership | Logical (schema-level) separation, often one physical database | Physical separation, typically one database per service |
| Scaling | Whole application scales together | Each service scales independently |
| Technology choice | One shared stack/language typically | Each service can use a different stack |
| Operational complexity | Low — one pipeline, one set of dashboards | High — many pipelines, service discovery, distributed tracing |
| Team autonomy | Moderate — shared release train | High — teams can release independently |
| Failure isolation | Weaker — a severe bug can affect the whole process | Stronger — one service crashing doesn’t crash others |
| Best for | Small-to-mid teams, evolving domains, early-to-mid stage products | Large orgs with many independent teams and mature DevOps |
How the module boundary maps to an API boundary
A key architectural insight: a well-built modular monolith’s internal module interface (like the InventoryFacade shown earlier) is designed to look and behave the way a real microservice API would. This is deliberate — it means that if a module genuinely needs to become an independent microservice later (say, because its scaling needs diverge sharply from the rest of the system), the team can replace the in-process call with a network call (e.g. turning InventoryFacade into a REST/gRPC client) with comparatively small, localised changes — because the boundary, the contract, and the data ownership were already correct.
Amazon’s own well-documented evolution went from a large monolithic application (obidos) toward service-oriented architecture over many years, precisely because certain capabilities (like the shopping cart or the recommendations engine) developed scaling and team-ownership needs that diverged sharply from the rest of the application — the same story many companies repeat today, but now with modular monoliths as the deliberate, well-organised starting point rather than an accidental tangled one.
REST API design at the boundary of a modular monolith
Even though modules talk to each other in-process, the modular monolith as a whole still exposes external REST (or GraphQL) APIs to clients. Good practice is to keep these external API controllers thin, routing straight into each module’s public interface, and to avoid letting one controller reach into multiple modules’ internals to “assemble” a response — instead, compose the response by calling each relevant module’s public interface and combining the results at the API layer.
15. Design Patterns & Anti-patterns
Design patterns that fit naturally
- Facade Pattern: Each module’s public interface (like
InventoryFacade) is a textbook Facade — a simplified, controlled entry point hiding internal complexity. - Observer Pattern (via domain events): Modules react to what happened elsewhere without depending directly on the source module, exactly like the classic Observer pattern.
- Repository Pattern: Each module accesses its own data exclusively through its own repository abstraction, never raw SQL scattered across the codebase.
- Anti-corruption Layer (from DDD): When a module must interact with a messy external system or legacy code, it wraps that interaction behind a translation layer so the messiness never leaks into the module’s own clean domain model.
- Ports and Adapters (Hexagonal Architecture): Each module defines “ports” (interfaces) for what it needs (e.g. a payment gateway) and “adapters” implement them — making it easy to swap implementations (e.g. a test double in unit tests) without touching business logic.
Common anti-patterns to avoid
Shared Mutable Entities
- Passing a live JPA/Hibernate entity object across a module boundary lets the receiving module accidentally modify data it doesn’t own.
- Fix: Pass immutable DTOs or records across boundaries instead.
Direct Database Access
- One module querying another module’s tables directly “just this once for a report.”
- Fix: Always go through the owning module’s public interface, even for reporting; build read models if needed.
The “God Module”
- A “Common” or “Core” or “Utils” module that keeps absorbing more and more logic until it depends on everything and everything depends on it.
- Fix: Keep shared kernels intentionally tiny — just true cross-cutting types, not business logic.
Circular Module Dependencies
- Module A calls Module B, and Module B calls back into Module A, creating a dependency cycle that makes both modules impossible to reason about or extract independently.
- Fix: Use events to break the cycle, or introduce a third coordinating module/service.
Worth knowing for interviews: a “distributed monolith” is the opposite failure mode — a system split into multiple deployed services (looking like microservices from the outside) but so tightly coupled internally (shared databases, synchronous chains of calls, coordinated releases) that it has all of microservices’ operational cost with none of a monolith’s simplicity or a true microservice architecture’s independence. A well-built modular monolith is architecturally healthier than a poorly-split distributed monolith.
16. Best Practices & Common Mistakes
Best practices
- Design module boundaries around business capabilities, not technical layers. “Orders,” “Payments,” “Inventory” — not “Controllers,” “Services,” “DAOs.”
- Make every cross-module dependency explicit and minimal. If Module A needs something from Module B, expose exactly that, through an interface — never more.
- Automate boundary enforcement from day one. Don’t rely on code review alone; add ArchUnit-style tests (or your framework’s equivalent) to the CI pipeline before boundaries have a chance to erode.
- Give each module its own schema or clearly namespaced tables, and never allow raw cross-module SQL.
- Prefer events for “notify,” prefer direct calls for “ask and wait.” Don’t force everything into one communication style.
- Keep the shared kernel small and boring. If it starts to contain business logic, that’s a sign it should live inside a specific module instead.
- Write module-level tests, not just end-to-end tests. Each module should be independently testable through its public interface, with internals mocked/stubbed at the boundary.
- Document the module map — ideally auto-generated from code (e.g. via Spring Modulith’s documentation generation) so it never goes stale.
- Revisit boundaries as understanding grows. Early boundaries are informed guesses; expect to refine them as the domain becomes clearer — that’s far cheaper to do inside one codebase than across separately deployed services.
Common mistakes
- Treating “modular” as optional once deadlines hit. Boundaries erode fastest under time pressure — this is exactly when automated checks matter most.
- Splitting modules by technical layer instead of business capability, which just recreates the traditional layered monolith with extra ceremony.
- Over-engineering event-driven communication for things that are really simple, synchronous “ask and wait” operations — not everything needs to be asynchronous.
- Forgetting that “modular” is a discipline, not a framework feature. Installing Spring Modulith or a similar tool does not, by itself, prevent bad boundaries — the team still has to design them well.
- Waiting too long to split off a genuinely divergent module — if one module’s scaling, security, or team-ownership needs are truly different from the rest, clinging to “we’re a modular monolith” past that point recreates some of the pain a modular monolith was meant to avoid.
17. Real-World & Industry Examples
Shopify
Core commerce platform organised as a modular monolith (“components”) at massive scale, absorbing Black Friday spikes across thousands of instances.
Segment
Publicly documented moving from ~140 microservices back to a well-structured monolith after concluding distribution cost exceeded distribution benefit for their team size.
Basecamp
Long-time advocate of “majestic monolith” architecture built with strong internal modularity — a small team shipping a mature product at high velocity.
Where each style tends to appear
| Company Type | Common Architecture Choice | Why |
|---|---|---|
| Early-stage startups | Modular monolith | Small team, evolving domain, need to move fast without distributed complexity |
| Mid-size SaaS companies | Modular monolith, sometimes with 1–2 extracted services | Balance of team growth and operational simplicity; extract only proven bottlenecks |
| Large tech companies with hundreds of engineering teams | Microservices (often evolved from an earlier monolith) | Need independent deployment and scaling across many autonomous teams |
| Companies that over-adopted microservices early | Consolidating back toward modular monoliths for some domains | Reducing operational overhead where independent scaling wasn’t actually needed |
The pattern across these public engineering accounts is consistent: architecture decisions should follow actual organisational and scaling needs, not fashion. Companies with genuinely independent teams and divergent scaling needs benefit from microservices. Companies without that yet — which is most companies, most of the time — get more value from a well-structured modular monolith, and can extract specific modules later when a real, measured need appears.
18. FAQ, Summary & Key Takeaways
Is a modular monolith the same as a “monolith”?
No. A plain monolith just means “one deployable unit” — it says nothing about internal organisation, and can easily become a tangled Big Ball of Mud. A modular monolith is a deliberate design discipline layered on top: one deployable unit, with clear, enforced internal boundaries between business capabilities.
Should every new project start as a modular monolith?
For most small-to-mid-sized teams, yes — it offers the best balance of structure and operational simplicity while the business domain is still being discovered. Very large organisations with many independent teams from day one, or systems with proven, extreme, divergent scaling needs per capability, may have good reasons to start with (or move toward) microservices sooner.
Can a modular monolith use multiple databases?
Yes. Many modular monoliths give each module its own schema within one physical database instance for simplicity, but it’s equally valid — and gives more future flexibility — for each module to have its own separate physical database while the application remains a single deployable unit.
How do you migrate a modular monolith to microservices later?
Because module boundaries, public interfaces, and data ownership are already explicit, migration typically means: picking one module with a genuinely divergent need, standing it up as an independent service with its own database, and replacing the in-process calls to it with network calls (REST/gRPC) behind the same interface contract — done one module at a time, not all at once.
What’s the biggest risk specific to modular monoliths?
Boundary erosion under deadline pressure, and shared-process failure isolation (a severe bug in one module can still affect the whole application, unlike in properly isolated microservices). Both are managed with automated boundary tests, careful error handling, and resource isolation techniques like bulkheading.
Summary
A modular monolith is a single, deployable application internally organised into clearly bounded modules, each owning its own logic and data, communicating through explicit public interfaces and in-process events rather than direct internal access. It solves the disorganisation of a tangled monolith without paying the network, operational, and consistency costs of microservices before they’re actually needed. It scales like a normal web application — horizontally, behind a load balancer — and, because its internal boundaries mirror what a real service boundary would look like, it leaves the door open to extract genuinely divergent modules into independent microservices later, with far less risk than starting distributed from day one.
Key Takeaways
- A modular monolith = one deployment unit + strictly enforced internal module boundaries.
- Modules communicate through public interfaces (synchronous) or domain events (asynchronous) — never by reaching into each other’s internals.
- Each module owns its own data; cross-module database access is forbidden even though it’s physically one database.
- It captures most of microservices’ structural benefits while keeping operations as simple as a normal monolith.
- Its biggest risks are boundary erosion over time and shared-process failure isolation — both manageable with automated tooling and discipline.
- It is not a permanent cage — well-designed modules can be extracted into real microservices later, if and when a genuine need appears.
- Automated architecture tests (ArchUnit, Spring Modulith verifications) are not optional — they are what stop a modular monolith from silently regressing into a big ball of mud.
- Observability must be per-module, not just per-application — otherwise you lose the very clarity you built modules for.
A modular monolith gives you the discipline of microservices with the operational cost of a normal web app — and, if you ever truly need to distribute, you already know exactly where the lines are.