← Return to Base

Spring Boot Modular Monoliths: A Better Starting Point Than Microservices

Architecture|

How to create meaningful boundaries in a Spring Boot application before paying the operational cost of distributed systems.

Microservices are often introduced as an answer to a team problem: independent delivery, clearer ownership, and the ability to scale parts of a system separately.

Those are real benefits. They are also expensive benefits. A distributed system adds network failure, deployment coordination, observability requirements, data-consistency decisions, and a larger operational surface area before the product has necessarily earned any of them.

For many teams, a modular monolith is a better starting point: one deployable application with explicit internal boundaries. It keeps local calls and a single operational unit while making the architecture honest about ownership and dependencies.

The problem is not one process

A monolith becomes difficult when its boundaries are implicit:

The deployment unit is not the root problem. The problem is uncontrolled coupling.

Splitting that application into services may hide some coupling behind HTTP or messaging, but it does not remove the underlying design problem. It can also make every design mistake more expensive to diagnose.

Start with business modules

A modular Spring Boot application should be organized around capabilities rather than global technical layers:

com.example.store
├── StoreApplication.java
├── orders
│   ├── OrderManagement.java
│   ├── OrderController.java
│   ├── OrderRepository.java
│   └── internal
├── inventory
│   ├── InventoryApi.java
│   ├── InventoryController.java
│   └── internal
└── customers
    ├── CustomerApi.java
    └── internal

The names are not the important part. The important part is that each module has an intentional public surface and keeps implementation details private.

An order module might expose a use case such as placeOrder(). It should not expose its JPA entities, repositories, or internal services for other modules to call directly. Inventory can answer a meaningful question—such as whether stock can be reserved—without allowing every caller to manipulate its persistence model.

Make boundaries executable

Package structure is a useful convention, but conventions decay. A modular monolith needs tests or architecture checks that fail when a boundary is crossed accidentally.

Spring Modulith provides one option for Spring Boot applications. Its default model treats direct subpackages of the application package as application modules. It can verify module relationships, detect cycles, and identify access to another module's internals.

A verification test can be small:

class ModularityTests {
    @Test
    void modulesShouldRespectTheirBoundaries() {
        ApplicationModules.of(StoreApplication.class).verify();
    }
}

The specific tool is less important than the invariant: an architectural boundary should be checked by automation rather than remembered by every developer.

Prefer use cases over shared internals

The most common failure mode is a shared “common” package that becomes a back door around every module. A second is allowing one module to import another module's persistence classes because it is convenient.

Prefer a small API at the boundary:

public interface Inventory {
    Reservation reserve(ProductId productId, Quantity quantity);
}

The implementation can change from a local method call to an event or remote service later. The rest of the application depends on a business capability, not on the current storage or transport mechanism.

That does not mean every method needs an interface. Abstraction has a cost too. Introduce a boundary where there is a meaningful capability, ownership decision, or likely change—not merely because a class exists.

Keep transactions honest

One advantage of a monolith is that a transaction can often cover a business operation without crossing a network boundary. That advantage should be used carefully.

Transactions should still be owned by a module and express a business operation. Avoid a service method that updates several unrelated modules' tables simply because they happen to share a database. If an operation crosses a real business boundary, consider a domain event, an explicit workflow, or a compensating action—even while the code runs in one process.

This makes a later extraction possible without pretending that extraction is free.

Testing becomes more useful

Modularity supports a useful testing pyramid:

The goal is not to eliminate integration tests. It is to stop every test from requiring the entire application and a shared set of fragile fixtures.

When a modular monolith is not enough

Choose separate services when there is a concrete reason, such as:

“We may need to scale someday” is not, by itself, a sufficient reason. Neither is the desire to look modern on an architecture diagram.

A practical migration path

A modular monolith does not trap a team in one deployment model. It creates a better extraction seam:

  1. ›Identify a capability with a stable boundary.
  2. ›Stop new code from reaching into its internals.
  3. ›Replace direct internal calls with a module API or domain event.
  4. ›Measure the operational and organizational reason for extraction.
  5. ›Move the module only when the benefits exceed the distributed-systems cost.

The important decision is not “monolith or microservices.” It is whether the system has boundaries that make either choice manageable.

The decision

For a new Spring Boot system, I would start with a modular monolith unless there is a specific reason not to. Organize by business capability, publish narrow APIs, verify boundaries automatically, and keep transactions and data ownership explicit.

That gives a team room to learn from a working system before committing to a fleet of distributed ones.

Sources