The Modular Monolith: An Architecture Pattern That Deserves More Attention

The software industry has a microservices fixation. Over the last decade, “monolith” became almost a pejorative, and teams moved to distributed architectures before they had the scale, team size, or operational maturity to justify them.

The result has been widespread over-engineering: systems that are harder to understand, harder to debug, harder to change, and more expensive to operate — not because distributed systems are inherently bad, but because the problems they solve weren’t actually present.

The modular monolith is not a compromise or a stepping stone. For many teams and products, it is the correct architecture. It solves the real problem with monoliths — accidental coupling and boundary erosion — without introducing distributed systems complexity.

What Makes a Monolith Bad Is Not That It’s a Monolith

The actual problems with the “big ball of mud” monolith aren’t:

  • Shared deployment unit
  • Single process
  • Single database

They are:

  • No enforced module boundaries: any code can call any other code
  • Implicit dependencies: understanding any one piece requires understanding everything
  • Boundary erosion over time: what started as separate concerns gradually bleeds together
  • Tangled domain logic: business rules scattered across layers

These problems can exist in microservices too — they’re just harder to see because the network makes them explicit rather than the code.

A modular monolith addresses the actual problem: it enforces module boundaries strictly, so the codebase can grow without becoming unmaintainable.

What a Modular Monolith Is

A modular monolith is a single deployable unit where the internal structure is divided into modules with explicit, enforced boundaries. Each module:

  • Owns its own domain logic
  • Owns its own persistence
  • Exposes a defined public API
  • Has no direct access to other modules’ internals

The key word is enforced. Module boundaries that are only conventions decay. Enforcement mechanisms — build-time checks, package-private visibility, separate Gradle/Maven modules — are what make the architecture durable.

Structuring Modules in Java

Java gives you several enforcement mechanisms. The most practical for teams not using JPMS is Maven/Gradle multi-module projects with strict dependency rules.

A typical structure:

myapp/
├── app/                    # Bootstrapping, Spring Boot entry point
├── modules/
│   ├── orders/             # Orders domain module
│   │   ├── api/            # Public API: interfaces, DTOs, events
│   │   └── internal/       # Domain logic, persistence, private
│   ├── inventory/
│   │   ├── api/
│   │   └── internal/
│   ├── payments/
│   │   ├── api/
│   │   └── internal/
│   └── users/
│       ├── api/
│       └── internal/
└── shared/                 # Truly shared utilities (logging, pagination, etc.)

The discipline: modules may only depend on other modules’ api submodule, never their internal.

In Gradle, this is expressible:

// orders/internal/build.gradle.kts
dependencies {
    implementation(project(":modules:orders:api"))
    implementation(project(":modules:inventory:api"))  // Can call inventory's public API
    // NOT: implementation(project(":modules:inventory:internal"))
}

With ArchUnit you can encode this as a test:

@AnalyzeClasses(packages = "com.myapp")
class ModuleBoundaryTest {

    @ArchTest
    static ArchRule noInternalCrossModuleDependencies = noClasses()
        .that().resideInAPackage("..modules..internal..")
        .should().dependOnClassesThat()
        .resideInAPackage("..modules..internal..")
        .andShould().haveFullyQualifiedName(Predicates.not(
            name -> name.contains(currentModuleName()) // except own internal
        ));
}

Module Public APIs

Each module’s public API defines the contract the rest of the system depends on. It should contain:

  • Interfaces for services the module provides
  • DTOs/records for input and output data
  • Domain events the module publishes
  • Nothing about persistence — no JPA entities, no repository interfaces
// orders/api — public contract

// What other modules can do with orders
public interface OrderService {
    OrderId placeOrder(PlaceOrderCommand command);
    Optional<OrderSummary> findOrder(OrderId id);
    void cancelOrder(OrderId id, CancellationReason reason);
}

// Commands — input data
public record PlaceOrderCommand(
    UserId customerId,
    List<OrderLineItem> items,
    ShippingAddress shippingAddress
) {}

// Read models — output data
public record OrderSummary(
    OrderId id,
    UserId customerId,
    OrderStatus status,
    Money total,
    Instant placedAt
) {}

// Events — things that happened
public record OrderPlaced(
    OrderId orderId,
    UserId customerId,
    List<OrderLineItem> items,
    Instant occurredAt
) {}

The internal package contains:

  • JPA entities (if using JPA)
  • Repository implementations
  • Domain services
  • The actual business logic

None of this leaks out.

Cross-Module Communication

Modules communicate in two ways:

Synchronous calls

Direct method calls through the public API interface. The calling module holds a reference to the interface, not the implementation:

// In payments/internal — calling inventory via its public API
@Component
public class PaymentProcessor {

    private final InventoryService inventoryService; // From inventory/api

    public PaymentResult processPayment(PaymentRequest request) {
        var reservation = inventoryService.reserveItems(request.items());
        // ... payment processing ...
    }
}

This is just dependency injection. In Spring, the implementation from inventory/internal gets wired in. The payments module never has a compile dependency on inventory/internal.

Asynchronous events

For decoupled communication, modules publish domain events via a simple in-process event bus:

// orders/internal publishes
eventPublisher.publish(new OrderPlaced(orderId, customerId, items, Instant.now()));

// inventory/internal subscribes
@EventListener
public void onOrderPlaced(OrderPlaced event) {
    inventoryService.reserveForOrder(event.orderId(), event.items());
}

In Spring, ApplicationEventPublisher provides this out of the box. Events are in-process and synchronous by default, which is fine for most use cases. If you need async, Spring’s @Async or a background executor works without infrastructure.

Database Separation

Each module owns its persistence. This is the hardest discipline to maintain but arguably the most important.

Practical approaches:

Schema-per-module in shared database: Each module has its own schema. Cross-module JOINs are explicitly banned.

-- orders module uses orders schema
CREATE TABLE orders.orders (...);
CREATE TABLE orders.order_lines (...);

-- inventory module uses inventory schema
CREATE TABLE inventory.products (...);
CREATE TABLE inventory.stock_levels (...);

Separate databases: More isolation, more operational overhead. Worth it when modules have meaningfully different scaling or availability requirements.

If a read view needs data from multiple modules, options are:

  1. Query each module’s service and assemble in the application
  2. Use a read model (CQRS) that subscribes to events from multiple modules
  3. Accept denormalization — the read module maintains its own copy of relevant data

What you don’t do: JOIN across module boundaries. That coupling is as bad as calling internal code.

Testing Modules Independently

One of the real advantages of strict module boundaries: you can test each module in complete isolation.

@SpringBootTest(classes = OrdersModuleConfiguration.class)
// Only loads the orders module's Spring context
class OrdersModuleIntegrationTest {

    @Autowired
    OrderService orderService;

    @MockBean
    InventoryService inventoryService; // Mock the inventory boundary

    @Test
    void placingOrder_reservesInventory() {
        when(inventoryService.reserveItems(any())).thenReturn(ReservationId.generate());

        var orderId = orderService.placeOrder(new PlaceOrderCommand(...));

        assertThat(orderId).isNotNull();
        verify(inventoryService).reserveItems(any());
    }
}

The module under test loads only its own context. Dependencies on other modules are mocked at the boundary. This is fast, reliable, and tests the right thing.

When to Extract a Service

A modular monolith gives you a natural migration path to microservices if you ever genuinely need them. The signals that a module is ready for extraction:

  • Independent scaling requirements: the inventory module gets 50× the read traffic of other modules
  • Independent deployment requirements: a team needs to deploy payments independently of the rest
  • Technology isolation: the data science team wants to own the recommendation engine in Python
  • Organizational independence: a team is large enough to own a service end-to-end

Notice what is not on this list: “we’re doing microservices because that’s what modern companies do.”

The extraction is mechanical when the module boundary is clean. The api package becomes a network contract. The event bus becomes a message queue. The in-process call becomes an HTTP or gRPC call. Nothing else changes.

If your module boundaries are not clean when you try to extract, you’ll spend months untangling accidental coupling that should never have existed.

Comparison With Microservices

Concern Modular Monolith Microservices
Deployment Single artifact Independent per service
Module isolation Enforced by build Enforced by network
Operational complexity Low High
Distributed transactions Not needed Hard problem
Debugging across modules Easy — single process Hard — requires distributed tracing
Scaling Vertical + horizontal Independent per service
Team independence Limited Strong
Technology diversity Low High

The modular monolith wins on everything except deployment independence and team independence. For many teams, those two things don’t justify the operational overhead.

The Honest Assessment

Most startups, scale-ups, and SMEs should start with a modular monolith. The team is small enough that independent deployment is not a real constraint. The operational overhead of microservices is a tax on velocity that small teams cannot afford.

Build the module boundaries correctly. Keep them enforced. When the system grows to the point where microservices genuinely add value, extract the modules that need it. You’ll be glad you started with clean boundaries.

The modular monolith is not a lesser architecture. It is frequently the more appropriate one.

architecturemodular-monolithmicroservicesdomain-driven-design
← All articles