Modular Monolith: The Architecture Between Monolith and Microservices

Modular Monolith Architecture: The Best of Both Worlds

The industry swung hard toward microservices, and now teams are paying the complexity tax — distributed transactions, network latency, operational overhead for dozens of services, and debugging distributed traces. Modular monolith architecture offers a middle ground: the modularity and domain isolation of microservices with the simplicity of a single deployment unit. Therefore, this guide explains how to design module boundaries, enforce isolation, and create a clear migration path to microservices if you ever need it.

Why Not Just Microservices?

Microservices solve real problems — team autonomy, independent scaling, technology diversity. But they also create real problems. A simple feature that touches three microservices requires coordinating three deployments, handling distributed transactions, and debugging across three different log streams. Moreover, for teams smaller than 50 engineers, the operational overhead of microservices often exceeds the organizational benefits.

The modular monolith gives you strong module boundaries (like microservice boundaries) without the distributed systems complexity. Each module owns its domain, has its own database schema (or database), and communicates with other modules through well-defined APIs. However, it all deploys as a single application, shares one process, and uses local function calls instead of network requests.

Microservices Trade-offs:
  ✅ Independent deployment    ❌ Network latency between services
  ✅ Independent scaling        ❌ Distributed transactions
  ✅ Technology diversity       ❌ Operational complexity (50+ services)
  ✅ Team autonomy              ❌ Debugging distributed traces

Modular Monolith Trade-offs:
  ✅ Module isolation           ❌ Single deployment unit
  ✅ Simple operations          ❌ Shared scaling
  ✅ Local transactions         ❌ Same technology stack
  ✅ Easy debugging             ❌ Requires discipline to maintain boundaries

Sweet spot: 5-30 engineers, <20 bounded contexts, moderate scale

Designing Module Boundaries

Module boundaries should follow Domain-Driven Design bounded contexts. Each module owns a business capability end-to-end: its domain model, its database tables, its business rules, and its public API. No module reaches into another module's database tables or internal classes. Furthermore, the public API of each module should be a narrow interface — only expose what other modules genuinely need.

// Project structure — each module is a separate package/project
// com.company.app/
//   ├── order/           -- Order module
//   │   ├── api/         -- Public interface (other modules use ONLY this)
//   │   │   ├── OrderService.java
//   │   │   └── OrderDTO.java
//   │   ├── internal/    -- Internal implementation (not accessible outside)
//   │   │   ├── OrderEntity.java
//   │   │   ├── OrderRepository.java
//   │   │   └── OrderValidator.java
//   │   └── events/      -- Events this module publishes
//   │       └── OrderPlacedEvent.java
//   ├── inventory/       -- Inventory module
//   │   ├── api/
//   │   │   ├── InventoryService.java
//   │   │   └── StockDTO.java
//   │   ├── internal/
//   │   └── events/
//   └── payment/         -- Payment module

// Order module's public API — the ONLY thing other modules can use
public interface OrderService {
    OrderDTO placeOrder(CreateOrderRequest request);
    OrderDTO getOrder(UUID orderId);
    List<OrderDTO> getOrdersByCustomer(UUID customerId);
    void cancelOrder(UUID orderId);
}

// Internal implementation — NOT accessible outside the module
class OrderServiceImpl implements OrderService {
    private final OrderRepository orderRepo;
    private final InventoryService inventoryService;  // Uses inventory's public API
    private final EventPublisher eventPublisher;

    @Override
    @Transactional
    public OrderDTO placeOrder(CreateOrderRequest request) {
        // Check stock through inventory module's public API
        StockDTO stock = inventoryService.checkStock(request.getProductId());
        if (stock.getAvailable() < request.getQuantity()) {
            throw new InsufficientStockException(request.getProductId());
        }

        // Create order in this module's tables
        OrderEntity order = OrderEntity.create(request);
        orderRepo.save(order);

        // Publish event for other modules to react
        eventPublisher.publish(new OrderPlacedEvent(
            order.getId(), request.getProductId(), request.getQuantity()
        ));

        return OrderMapper.toDTO(order);
    }
}
Modular monolith architecture design
Each module owns its domain, database schema, and exposes only a narrow public API

Enforcing Module Boundaries

The biggest risk with a modular monolith is boundary erosion — developers taking shortcuts by accessing another module's internal classes or database tables directly. Without enforcement, your modular monolith degrades into a traditional big ball of mud. You need automated tools to enforce boundaries.

// ArchUnit tests — run in CI to enforce module boundaries
@AnalyzeClasses(packages = "com.company.app")
class ModuleBoundaryTests {

    @ArchTest
    static final ArchRule orderModuleInternalsArePrivate =
        classes()
            .that().resideInAPackage("..order.internal..")
            .should().onlyBeAccessed()
            .byClassesThat().resideInAnyPackage("..order..");

    @ArchTest
    static final ArchRule modulesOnlyUsePublicAPIs =
        classes()
            .that().resideInAPackage("..inventory.internal..")
            .should().notBeAccessed()
            .byClassesThat().resideInAPackage("..order..");

    @ArchTest
    static final ArchRule noDirectDatabaseAccess =
        noClasses()
            .that().resideInAPackage("..order..")
            .should().accessClassesThat()
            .resideInAPackage("..inventory.internal..");
}

// Spring Modulith — provides module isolation with dependency verification
// In pom.xml or build.gradle, use Spring Modulith's ApplicationModules
@SpringBootTest
class ModularityTests {
    @Test
    void verifyModularity() {
        ApplicationModules.of(Application.class).verify();
        // Fails if modules have undeclared dependencies
    }
}

Spring Modulith is particularly powerful for Java applications. It automatically detects modules, verifies dependency rules, generates module documentation, and supports event-driven communication between modules. Additionally, it provides @ApplicationModuleTest for testing modules in isolation.

Inter-Module Communication

Modules should communicate through two mechanisms: synchronous API calls (for queries) and asynchronous events (for commands/reactions). Direct API calls work for simple queries — "What's the current stock for product X?" Events work for reactions — "An order was placed, so reduce stock by 5."

Using in-process events (Spring ApplicationEvents, Guava EventBus) gives you the decoupling benefits of message queues without the operational overhead. If you later extract a module into a microservice, you swap the in-process event publisher for Kafka or RabbitMQ — the consuming code doesn't change.

Module communication patterns
Use synchronous calls for queries and async events for commands between modules

Migration Path to Microservices

A well-designed modular monolith is pre-factored for microservice extraction. When a module needs independent scaling, different deployment cadence, or a different technology stack, you extract it. The process is incremental: extract the module's database tables to a separate database, replace in-process API calls with REST/gRPC calls, replace in-process events with Kafka/RabbitMQ, and deploy the module as a separate service.

Critically, you only extract modules that have a clear operational reason to be separate. Most organizations find that 3-5 modules need extraction while the rest are perfectly happy in the monolith. Consequently, you get the benefits of microservices where needed without paying the complexity tax everywhere.

Architecture migration planning
Extract only modules with clear operational reasons — most are better in the monolith

Related Reading:

Resources:

In conclusion, the modular monolith is not a stepping stone to microservices — it's a legitimate architecture for most applications. Strong module boundaries, enforced through tooling, give you domain isolation without distributed systems complexity. Start modular, extract only when you have a proven operational need, and enjoy the simplicity of deploying one application instead of fifty.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top