Software ArchitectureSystem Design

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

TT
TopicTrick Team
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?

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:

  1. Each module owns its own domain and data — no module accesses another module's internal classes or tables directly
  2. Cross-module communication happens only through published, stable public APIs
  3. 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)OwnsDoes NOT own
IdentityUsers, sessions, roles, permissionsOrders, payments
CatalogProducts, categories, pricingStock levels, orders
InventoryStock levels, warehouse locationsPricing, users
OrdersOrder lifecycle, cart, checkoutUser credentials, pricing rules
PaymentsPayment methods, transactions, refundsOrder creation, inventory
NotificationsEmail, SMS, push notification deliveryBusiness 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:

text
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 allowed

Java: ArchUnit Tests

ArchUnit lets you write architecture rules as JUnit tests — violations fail the build:

java
@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:

java
// 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:

java
// 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

java
// 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

java
// 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

text
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
sql
-- 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

FeatureStandard MonolithModular MonolithMicroservices
Deployment complexityLowLowVery High (Kubernetes)
Development velocityHigh initially, degradesHigh (sustained)High for large teams
Internal couplingHigh (no walls)Low (enforced)Low (physical separation)
TestingOne test suiteOne test suitePer-service + contract tests
Transaction supportACID (shared DB)ACID or schema-isolatedSaga pattern (complex)
DebuggingStack traceStack traceDistributed tracing required
Team size sweet spot1–15 engineers10–100 engineers100+ engineers
Microservice migrationHard (no boundaries)Easy (boundaries exist)Already there

When to Extract a Module into a Microservice

Extract a module when one of these is true:

  1. Independent scaling need: The module's load is 10× other modules, and you can save significant cost by scaling only it
  2. Team autonomy: The module is owned by a separate team that needs to deploy independently multiple times per day
  3. Technology isolation: The module genuinely benefits from a different runtime (e.g., extracting video processing to a Python service with GPU support)
  4. 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.