in

Monolith vs Microservices: The Enterprise Java Decision

Microservices are a deployment and organizational decision, not a badge of quality. We break down the monolith vs microservices call for enterprise Java by team scale, data consistency and operational cost, and show how to build a modular monolith with Spring Modulith.

Diagram comparing a modular monolith and microservices by deployment unit and data ownership

The question we hear most often when an enterprise Java project kicks off is: "Should we go with microservices or a monolith?" It is the right question, but it usually gets asked at the wrong time and for the wrong reasons. Microservices are not a badge of quality; they are a deployment and organizational decision — and the bill usually arrives not in the first sprints but in the second year.

This article covers the criteria that should drive the decision, the real costs of both approaches, and the starting point we recommend for most enterprise projects — the modular monolith — with concrete Spring Boot code.

Let's define the terms first

Most of the debate comes from three different things being called by the same name.

A monolith is an application that runs as a single deployment unit: one process, usually one database. It is not a synonym for "spaghetti code"; messiness is a result of discipline, not architecture.

A modular monolith is still a single deployment unit; the difference is that module boundaries are enforced in code and at build time. The order module cannot reach into the inventory module's internal classes; it only calls its public API or listens to the events it publishes. Each module owns its own tables.

Microservices are independently deployed services that own their data and talk to each other over the network.

The critical point: the difference between a modular monolith and microservices is not code quality but the deployment unit and the process boundary. If you have drawn the logical boundaries correctly, moving between the two is a matter of writing adapters, not an architectural revolution. If you haven't, moving to microservices just relocates the mess onto the network.

The real cost of microservices: the distributed systems tax

The moment you turn a process boundary into a network boundary, four invoices arrive at once.

1. Network and partial failure

A method call within the same process takes nanoseconds and either succeeds or throws. The same call over the network takes milliseconds and has a third outcome: no answer. Timeouts, retries, circuit breakers, idempotency — none of these are optional; every one of them must be designed, tested and monitored.

2. Data consistency

In a single database, "create order + reduce stock + issue invoice" is one transaction: all or nothing. Across three services those three steps become a saga: every step needs a compensating action, and you need monitoring and an intervention process for the "order created but stock not reduced" case. In domains that require strong consistency, such as finance and inventory, this is the most expensive line item.

3. Operations

A separate CI/CD pipeline per service, configuration and secrets management, service discovery, distributed tracing, log correlation, an on-call rotation. Without this infrastructure you don't have a microservice architecture; you have a network of failures nobody can observe.

4. Organization

Conway's law also works in reverse: when a team of six operates 25 services, every developer owns four of them and the independent-deployment advantage exists only on paper. The biggest payoff of microservices is that teams can ship without waiting for each other; for a single team, that payoff doesn't exist.

The real cost of a monolith

A monolith isn't free either; its bill just has different line items.

  • Scaling as a whole: When report generation eats CPU, the order API in the same process slows down too. The fix is usually not a separate service but a worker profile started from the same codebase.

  • Deployment coupling: One broken test in one module blocks the whole release. This is a process problem, not an architectural one; it is solved with per-module test parallelization and feature flags.

  • Build and test time: The pipeline grows with the codebase. A Gradle/Maven multi-module layout and testing only the affected modules keep this manageable.

  • Technology lock-in: All modules move together on the same Java version and framework. In enterprise projects that is usually an advantage.

  • Erosion: If boundaries aren't enforced, you get a "big ball of mud" within three years. This is prevented with the tools covered below.

Note that most of these items are solved by modularization and CI discipline; none of them require a network boundary.

Seven questions for the decision

In architecture meetings we ask these seven questions in order. If your answer to the first five is "no", there is no justification for starting with microservices.

#

Question

If "yes"

1

Are there multiple teams that need to release independently?

A service boundary can follow the team boundary

2

Has the need to scale a component separately been proven by measurement?

Try a worker/profile split first; a service if that isn't enough

3

Does a component need a different runtime (e.g. a Python ML model)?

That component becomes a service, nothing else

4

Is regulatory isolation required (PCI scope, GDPR/KVKK data separation)?

A separate service and database for that domain

5

Is there operational maturity: CI/CD, tracing, central logging, on-call?

If not, build that first

6

Are the domain boundaries (bounded contexts) clear?

If not, microservices freeze the wrong boundary

7

Is strong consistency required across modules?

If so, keep them within one transaction boundary

The sixth question is the one most often skipped. In a new domain, boundaries shift during the first six months; moving a boundary in a monolith means moving a package, while in microservices it means a data migration and an API contract change.

Comparison: modular monolith vs microservices

Dimension

Modular monolith

Microservices

Deployment unit

One

Per service

Data consistency

ACID transaction

Saga / eventual consistency

Cross-module call

Method call, nanoseconds

HTTP/gRPC/message, milliseconds + failure modes

Scaling

Horizontal replicas + worker profiles

Independent per service

Team scale

1–3 teams

Many teams, per-service ownership

Operational cost

Low

High; needs a platform team

Debugging

One stack trace

Distributed tracing required

Development speed, first 6 months

High

Low (infrastructure setup)

Technology diversity

Limited

Free

Cost of a wrong boundary

Low (move a package)

High (data migration + contract)

Our default answer: the modular monolith

For most enterprise projects our recommendation is a modular monolith that keeps the path to microservices open. On Spring Boot we build it on three principles.

1. Module = package, boundary = build time

Every business capability is a module directly under the application package. Only the classes at the module root are public; sub-packages (such as internal) are closed to other modules.

com.acme.erp
├── ErpApplication.java
├── order
│   ├── OrderService.java          // public API
│   ├── OrderPlaced.java           // published event (record)
│   └── internal
│       ├── OrderRepository.java
│       └── OrderEntity.java
├── inventory
├── billing
└── customer

We protect the boundary with a test, not a comment. Spring Modulith provides this directly:

<dependency>
  <groupId>org.springframework.modulith</groupId>
  <artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.modulith</groupId>
  <artifactId>spring-modulith-starter-test</artifactId>
  <scope>test</scope>
</dependency>
// order/package-info.java
@org.springframework.modulith.ApplicationModule(
    displayName = "Order",
    allowedDependencies = { "customer" }   // no direct dependency on inventory or billing
)
package com.acme.erp.order;
class ModuleStructureTest {

    @Test
    void moduleBoundariesAreRespected() {
        ApplicationModules modules = ApplicationModules.of(ErpApplication.class);
        modules.verify();   // inventory -> order.internal access breaks the build
    }
}

verify() checks three things: cyclic dependencies, access to internal packages, and dependencies outside allowedDependencies. If you would rather not use Spring Modulith, the same rules can be written with ArchUnit; what matters is that the rule runs in the pipeline.

2. Communication between modules: an API call or an event — both are contracts

For synchronous needs we call the module's public service. When the continuation of a workflow is another module's responsibility, we publish an event; the order module doesn't know the inventory module exists.

@Service
@Transactional
public class OrderService {

    private final OrderRepository repository;
    private final ApplicationEventPublisher events;

    public OrderNumber place(OrderRequest request) {
        OrderEntity order = repository.save(OrderEntity.from(request));
        events.publishEvent(new OrderPlaced(order.getNumber(), order.lines()));
        return order.getNumber();
    }
}
// inventory module — no dependency on order, it just listens to the event
@Component
class InventoryReservation {

    @ApplicationModuleListener
    void on(OrderPlaced event) {
        reserve(event.lines());
    }
}

@ApplicationModuleListener combines three annotations: @TransactionalEventListener (the event is handled after the order transaction commits), @Async (requires @EnableAsync) and @Transactional(propagation = REQUIRES_NEW). So the stock reservation runs in its own transaction; it doesn't roll back the order, and it handles its own failures.

The subtle part is the event publication registry: with spring-modulith-starter-jpa on the classpath, every published event and the listeners that completed it are written to the database. If a listener fails, the record stays "incomplete" and can be resubmitted. That is an outbox guarantee without setting up Kafka — and when you later extract a module into a service, the event is routed to Kafka via @Externalized; the publishing service code doesn't change.

3. Data ownership at module level

Each module owns its tables. The rule is simple: no JOINs across modules, no JPA relationships to another module's entities. If the order needs customer data, it calls CustomerService.find(id) or copies what it received via the event into its own table. Schema-level separation (order.*, inventory.*) is preferred, so that when a module is extracted its tables move without a "whose table is this" debate.

With these three principles in place, you have a single deployment unit that is ready for microservices.

When to extract a service: concrete signals

A modular monolith is not a stance against microservices; it is preparation to extract one at the moment it is earned. We put service extraction on the table at these signals:

  • Measured asymmetric load: One module takes most of the CPU/memory on its own and a worker profile isn't enough (heavy PDF generation, image processing).

  • Different release cadence: One module changes several times a day while the others change monthly, and it has moved to a separate team.

  • Different runtime: The model has to run in Python.

  • Compliance isolation: The payment flow needs its own environment to narrow PCI scope.

  • Pipeline time: Even with per-module test parallelization, build time disrupts the team's flow.

Because the boundaries are already enforced, the extraction path is short: a REST/gRPC adapter is written over the module's public API, events are routed to a message broker, tables are moved, and callers are switched to the new endpoint gradually (strangler fig). In a monolith with unenforced boundaries the same job takes months; in an enforced module, weeks.

Five common mistakes

  1. The distributed monolith: Separate services, shared database, synchronous call chains. You pay the full cost of microservices and get none of the benefits.

  2. One service per entity: Splitting into "User service, Product service, Order service" follows the data model, not business capabilities; every workflow touches three services.

  3. Microservices with a four-person team: Infrastructure work overtakes product work.

  4. Leaving idempotency for later: Retries produce duplicate orders and duplicate invoices. This is designed in from day one, in a monolith or in microservices.

  5. Going live without correlation IDs: At the first production incident nobody can tell which request broke where.

Frequently asked questions

Is it hard to build microservices with Spring Boot?

Not on the framework side; Spring Boot, Spring Cloud and Resilience4j provide the building blocks. The difficulty is operational: the deployment pipeline, tracing, configuration and on-call. Without that infrastructure, microservices themselves become hard.

How long does it take to move from a modular monolith to microservices?

If module boundaries are enforced by tests, extracting a single module is typically a matter of a few weeks; the big items are the data migration and switching callers to the new endpoint. If boundaries were never enforced, the duration is unpredictable.

Are microservices faster?

No. An in-process call is hundreds of times faster than a network call. The gain from microservices is not speed but independent scaling and independent deployment.

Which projects should start with microservices from day one?

Those where domain boundaries are clear, several autonomous teams will work on the system, and a platform/infrastructure team is ready. If those three conditions don't hold together, a modular monolith is the lower-risk start.

Conclusion

The monolith-versus-microservices decision is not a matter of prestige; it is a function of team structure, consistency requirements and operational capacity. For most enterprise Java projects the lowest-risk path is to start with a modular monolith whose boundaries are enforced at build time, and to extract services only where the need is proven by measurement.

To assess the module boundaries of your existing system or shape the target architecture of a new project, see our software design and architecture service and our enterprise application development process. The discovery call and first estimate are free.