The Modular Monolith: The Architect's Sweet Spot for 2026 Engineering Teams

The Modular Monolith: The Architect's Sweet Spot for 2026 Engineering Teams
Table of Contents
- What Separates a Modular Monolith from a Big Ball of Mud?
- Defining Module Boundaries with Domain-Driven Design
- Enforcing Isolation: Language-Level Tools
- Module Public API Design
- Cross-Module Communication: Sync vs Async
- Database Strategy: From Shared Tables to Schema Isolation
- Directory Structure: Real-World Example
- Comparison Matrix: Monolith vs Modular Monolith vs Microservices
- When to Extract a Module into a Microservice
- Frequently Asked Questions
- Key Takeaway
What Separates a Modular Monolith from a Big Ball of Mud?
The difference is enforcement. Both are single deployable units. But in a Big Ball of Mud, any class can call any other class without restriction. In a Modular Monolith:
- Each module owns its own domain and data — no module accesses another module's internal classes or tables directly
- Cross-module communication happens only through published, stable public APIs
- These boundaries are enforced by tooling — tests fail, the build breaks, or the compiler rejects cross-boundary imports
Without enforcement, a modular monolith decays into a Big Ball of Mud within 9–18 months as developers take "one-off" shortcuts.
Defining Module Boundaries with Domain-Driven Design
Use Bounded Contexts from Domain-Driven Design to identify module boundaries. A bounded context is a business area where a specific set of concepts and rules apply consistently.
For an e-commerce platform:
| Module (Bounded Context) | Owns | Does NOT own |
|---|---|---|
| Identity | Users, sessions, roles, permissions | Orders, payments |
| Catalog | Products, categories, pricing | Stock levels, orders |
| Inventory | Stock levels, warehouse locations | Pricing, users |
| Orders | Order lifecycle, cart, checkout | User credentials, pricing rules |
| Payments | Payment methods, transactions, refunds | Order creation, inventory |
| Notifications | Email, SMS, push notification delivery | Business decisions |
The key insight: When the Orders module needs to know if a product is in stock, it calls Inventory.isAvailable(productId) through a well-defined interface — it never reads the inventory_items table directly.
Enforcing Isolation: Language-Level Tools
Go: internal Packages
Go's internal directory convention is enforced by the compiler — one of the most powerful built-in module isolation tools:
src/
├── identity/
│ ├── internal/ ↠ONLY importable by identity/ and sub-packages
│ │ ├── repository.go
│ │ └── hasher.go
│ ├── api.go ↠PUBLIC interface for identity module
│ └── user.go
├── orders/
│ ├── internal/ ↠ONLY importable by orders/ and sub-packages
│ │ └── repository.go
│ └── api.go
└── main.go
// This import FAILS at compile time:
import "github.com/myapp/identity/internal/repository" // from orders/
// Error: use of internal package not allowedJava: ArchUnit Tests
ArchUnit lets you write architecture rules as JUnit tests — violations fail the build:
@AnalyzeClasses(packages = "com.myapp")
class ArchitectureTest {
@ArchTest
static final ArchRule modules_must_not_access_private_internals =
noClasses()
.that().resideInAPackage("..orders..")
.should().accessClassesThat()
.resideInAPackage("..identity.internal..");
@ArchTest
static final ArchRule payments_must_not_depend_on_orders_internals =
noClasses()
.that().resideInAPackage("..payments..")
.should().dependOnClassesThat()
.resideInAPackage("..orders.internal..");
}
// If an Orders class imports from Identity's internal package: BUILD FAILS!Java: Jigsaw Modules (module-info.java)
Java's module system enforces at the compiler and JVM level:
// module-info.java for identity module
module com.myapp.identity {
exports com.myapp.identity.api; // Public API — others can import this
// com.myapp.identity.internal is NOT exported — invisible to other modules
requires com.myapp.shared; // Can import from shared module
}
// module-info.java for orders module
module com.myapp.orders {
exports com.myapp.orders.api;
requires com.myapp.identity; // Can use identity's exported API only
// com.myapp.identity.internal is inaccessible — JVM enforces this
}Module Public API Design
Each module should expose a clean Facade — a single class that represents everything the module offers the outside world:
// identity/api/IdentityModule.java — the module's public surface
public interface IdentityModule {
UserDto findUser(UserId id);
boolean hasPermission(UserId userId, Permission permission);
UserSummary getUserSummary(UserId id);
// Nothing about passwords, tokens, sessions — those are internal
}
// orders/service/OrderService.java — uses only the public facade
public class OrderService {
private final IdentityModule identity; // ↠depends on interface, not internals
public Order createOrder(UserId userId, List<OrderItem> items) {
if (!identity.hasPermission(userId, Permission.PLACE_ORDER)) {
throw new InsufficientPermissionException();
}
UserSummary user = identity.getUserSummary(userId);
return Order.create(user, items);
}
}Cross-Module Communication: Sync vs Async
Synchronous (Direct Call): Use when the operation needs an immediate response
// Orders module calls Inventory module synchronously — needs to know stock NOW:
boolean available = inventoryModule.isAvailable(productId, quantity);
if (!available) throw new OutOfStockException();Asynchronous (In-Memory Event Bus): Use when the operation is a side effect
// Orders module publishes event — Notifications module reacts independently:
public class OrderService {
private final EventBus eventBus;
public Order placeOrder(...) {
Order order = createOrderInternal(...);
// Notify: order placed. Notifications module will send email
// Orders module doesn't know or care WHO listens:
eventBus.publish(new OrderPlacedEvent(order.id(), order.userId(), order.total()));
return order;
}
}
// Notifications module — subscribes to OrderPlacedEvent:
@EventListener
public class OrderConfirmationEmailHandler {
public void handle(OrderPlacedEvent event) {
emailService.sendOrderConfirmation(event.userId(), event.orderId());
}
}Why in-process events over Kafka? Because you're in the same process, events are delivered synchronously within the same transaction (with Spring's @TransactionalEventListener) or instantly after commit — no Kafka broker required, no eventual consistency, no message ordering issues.
Database Strategy: From Shared Tables to Schema Isolation
Shared table names (minimal isolation) → Use module prefix: id_users, order_items, inv_stock
Separate database schemas (better) → identity schema, orders schema, inventory schema
Separate DB users with permissions → Each module's DB user can only access its schema
Separate physical databases (microservice)→ Full isolation, but now you need distributed transactions-- PostgreSQL schema-per-module approach:
CREATE SCHEMA identity;
CREATE SCHEMA orders;
CREATE SCHEMA inventory;
CREATE TABLE identity.users (...); -- Orders cannot query this directly
CREATE TABLE orders.order_items (...); -- Identity cannot query this directly
CREATE TABLE inventory.stock_levels (...); -- Orders cannot query this directly
-- Database user for orders module:
CREATE USER orders_service ...;
GRANT ALL ON SCHEMA orders TO orders_service;
-- orders_service has NO access to identity or inventory schemas!Comparison Matrix
| Feature | Standard Monolith | Modular Monolith | Microservices |
|---|---|---|---|
| Deployment complexity | Low | Low | Very High (Kubernetes) |
| Development velocity | High initially, degrades | High (sustained) | High for large teams |
| Internal coupling | High (no walls) | Low (enforced) | Low (physical separation) |
| Testing | One test suite | One test suite | Per-service + contract tests |
| Transaction support | ACID (shared DB) | ACID or schema-isolated | Saga pattern (complex) |
| Debugging | Stack trace | Stack trace | Distributed tracing required |
| Team size sweet spot | 1–15 engineers | 10–100 engineers | 100+ engineers |
| Microservice migration | Hard (no boundaries) | Easy (boundaries exist) | Already there |
When to Extract a Module into a Microservice
Extract a module when one of these is true:
- Independent scaling need: The module's load is 10× other modules, and you can save significant cost by scaling only it
- Team autonomy: The module is owned by a separate team that needs to deploy independently multiple times per day
- Technology isolation: The module genuinely benefits from a different runtime (e.g., extracting video processing to a Python service with GPU support)
- Compliance: Regulatory requirements demand physical isolation (PCI-DSS for payment processing data)
Do NOT extract because:
- You think it might scale in the future
- The module is "logically separate" (that's what module boundaries are for — they don't require physical separation)
- You want to use a different language for its own sake
Frequently Asked Questions
How big should a module be? A module should correspond to one Bounded Context — a cohesive set of business concepts with a clear owner. Practically, this often maps to one "product team" or one vertical of the business. A module with more than 50,000 lines of code might be a signal it should be further divided into sub-modules. A module with under 500 lines is probably too fine-grained.
Should each module have its own database schema even in development? Yes, at minimum use table prefixes. Schema isolation in development prevents the temptation of cross-module SQL joins — which are the most common way boundaries erode. The discipline of using only public APIs for cross-module data access must be practiced from day one.
Key Takeaway
The Modular Monolith is the architecture that scales with your team's growth. At 10 engineers, you get all the benefits of a monolith with the organizational structure of a well-designed system. At 80 engineers, the strict boundaries you enforced from day one allow you to extract modules into microservices one at a time — without a big-bang rewrite. The discipline of enforcement tooling (ArchUnit, Go internal, module-info.java) is the non-negotiable investment that makes this architecture sustainable.
Read next: When Microservices Hurt: Anti-patterns and Pitfalls →
Part of the Software Architecture Hub — comprehensive guides from architectural foundations to advanced distributed systems patterns.
