Most teams that ask how to split a monolith into microservices have a slow, tangled codebase and hope that network boundaries will force the order the code lacks. They usually get the same tangle, now with network calls and distributed transactions.

This guide covers when a migration from monolith to microservices pays off, why a modular monolith comes first in most Java systems, and how to extract services one at a time once the boundaries are proven.

Microservices vs monolith: what each one costs

A monolith is one deployable unit. A microservice architecture splits the system into services that are built, deployed and scaled separately, each owning its data. The split helps with two problems: teams blocking each other on one release, and parts of the system with very different scaling or availability needs. Istio's maintainers put the condition precisely when they merged their own control plane back into one binary: microservices are a great pattern "when they map services to disparate teams that deliver them, or when the value of independent rollout and the value of independent scale are greater than the cost of orchestration" (Istio, March 2020).

Everything else gets more expensive. In-process calls become network calls that time out. A database transaction across two modules becomes a saga or an eventual-consistency problem. Each service needs its own pipeline, monitoring, alerting and on-call owner. Martin Fowler calls this the microservice premium, a cost that "often gets projects into serious trouble" when the system is not complex enough to justify it.

MonolithModular monolithMicroservices
DeployablesOneOneOne per service
Module boundariesBy convention, usually erodedEnforced by tests or the buildEnforced by the network
DataOne schema, joins everywhereOne database, a schema per moduleA database per service
Cross-module consistencyACID transactionsACID transactions, events between modulesSagas and eventual consistency
ScalingThe whole applicationThe whole applicationPer service
FitsOne small teamOne to several teams sharing a releaseSeveral teams that need independent releases
Operating costLowLowA platform: CI/CD, tracing, service discovery, on-call per service

When microservices are the wrong answer

Three published cases show the cost side clearly.

  • Amazon Prime Video built its audio and video quality monitoring as distributed components orchestrated with AWS Step Functions. Moving it into a single process "reduced our infrastructure cost by over 90%", because data no longer had to pass between components through S3 (Prime Video Tech, March 2023, archived copy).
  • Segment split its event-delivery system into one service per destination. When the team decided to reverse it, "the first item on the list was to consolidate the now over 140 services into a single service" (Segment, 2018, archived copy).
  • Istio merged its control plane components into one binary, istiod, in version 1.5, because none of the conditions for microservices applied to them.

None of these teams lacked skill. Each found that the services split along technical lines (pipeline stages, destinations, components) rather than along team ownership, so the network boundaries added cost without adding independence.

A decision test to run before you split anything

Answer these for the system as it runs today, with evidence rather than opinion.

QuestionEvidence to checkA "yes" means
Do several teams wait for each other to release?Release coordination meetings, merge queues, features held for another team's fixA split can give teams independent releases
Does one part need very different scaling or availability?CPU and memory profiles per endpoint; one feature forcing the whole cluster to scaleThat part is a candidate to extract
Does one part need its own release cadence or runtime?A model-serving component, a regulated module with its own approval processThat part is a candidate to extract
Are the module boundaries already clean?No cyclic dependencies; a module test such as Spring Modulith's verify() passesYou can split without building a distributed monolith
Can one module own each table?No cross-module joins or writes in the SQLThe data can follow the code
Can you run the platform?Per-service pipelines, distributed tracing, an on-call rotaYou can operate what you build

If the first three answers are "no", stay with one deployable and invest in modules. If one of them is "yes" but the boundaries or the data are not clean, modularize first; that work is needed either way. Extract a service only where the first three point to a specific part of the system and the last three are already true for it.

The modular monolith as a first step

A modular monolith keeps one deployable but enforces the boundaries a microservice architecture would draw: each module has a public API, private internals and its own tables, and modules talk to each other through that API or through events. It gets most of the maintainability of microservices without the network, and it turns a later extraction into a packaging change instead of a redesign.

Shopify is the best-documented example. Its core Rails application "has been worked on for over a decade by more than a thousand developers", and the team concluded that "going from monolith to modular monolith was the next logical step" rather than a split into services (Shopify Engineering, February 2019). Fowler observed the same pattern across the industry: "almost all the successful microservice stories have started with a monolith that got too big and was broken up" (MonolithFirst).

Three architectures on the path from a monolith: first a monolith whose modules call each other freely over one shared database; then a modular monolith, still one deployable, where modules talk only through declared APIs and events and each owns its schema; then a core application with only the modules that need it, here orders and billing, extracted as separately deployed services with their own databases, connected by events on Kafka.THREE ARCHITECTURES, ONE CODEBASE’S PATH1. Monolithone deployable, one schema2. Modular monolithone deployable, enforced modules3. Services where neededseparate deployables and dataordersbillingcatalogusersone shared databaseordersbillingcatalogusersone schema per modulecore appcatalogusersordersown DBbillingown DBevents on Kafkahidden dependencydeclared API or eventseparately deployed service
The path most Java systems should take: enforce module boundaries inside one deployable first, then extract only the modules that need their own deployment or scaling.

Enforcing modules in Spring Boot with Spring Modulith

In a Spring Boot application, Spring Modulith treats each direct sub-package of the main application package as an application module and lets a test check the rules. ApplicationModules.verify() "throws an exception in case of any architectural violation being detected" (Spring Modulith documentation): a dependency cycle between modules, or code that reaches into another module's internal packages.

class ModularityTests {

    ApplicationModules modules = ApplicationModules.of(ShopApplication.class);

    @Test
    void verifiesModuleBoundaries() {
        modules.verify(); // fails on cycles and on access to another module's internals
    }
}

Run it in CI from the first day. On an existing monolith it will fail, and the list of violations is the first honest map of how coupled the code is.

Between modules, replace direct calls with events where the caller does not need an answer. An @ApplicationModuleListener runs in its own transaction after the publishing transaction commits, and Spring Modulith's event publication registry stores each publication until its listener completes, so a failed listener can be retried instead of losing the event.

@Service
@RequiredArgsConstructor
class OrderManagement {

    private final ApplicationEventPublisher events;

    @Transactional
    public void complete(Order order) {
        order.complete();
        events.publishEvent(new OrderCompleted(order.getId()));
    }
}

@Component
class InventoryManagement {

    @ApplicationModuleListener // runs after the order transaction commits
    void on(OrderCompleted event) {
        // reserve stock in the inventory module's own tables
    }
}

The same event can later leave the process. Annotating it with @Externalized and adding spring-modulith-events-kafka publishes it to a Kafka topic after the transaction commits, which is how a module's consumers move to another service without the publisher changing.

@Externalized("orders.completed::#{#this.orderId()}")
record OrderCompleted(UUID orderId) {}

Finding the seams with DDD bounded contexts

Module boundaries drawn along technical layers (controllers, services, repositories) or along database tables do not hold. The boundaries that hold follow the business: a bounded context in domain-driven design is the part of the system in which a term has one meaning and one owner. "Order" means a basket in sales, a pick list in the warehouse and an invoice line in billing; three contexts, three models, three modules.

Four sources of evidence point to the seams in an existing codebase:

  • Language. Run an event storming session with the people who use the system and write down the business events in order. Where the vocabulary changes, there is usually a boundary.
  • Change coupling. Files that change together in the same commits belong together. git log --name-only over the last year, grouped by commit, shows which packages move as one and which never touch.
  • Data ownership. For each table, find the code that writes it. A table written from three modules is a boundary problem to solve before any split.
  • Team ownership. If one team already owns a part of the system end to end, that part is a natural module.

Start the move with a module that has few inbound dependencies and clear data ownership, often something at the edge such as notifications, document generation or reporting, not the core domain.

Data decomposition: the hardest part of the migration

Code moves easily; data does not. Most failed migrations from monolith to microservices end as a distributed monolith: services deployed separately but sharing one database, so a schema change still needs every team, and every release still needs coordination.

Decompose the data in the same order as the code, inside the monolith first:

  1. A schema per module in the existing database. Move each table to the schema of the module that writes it.
  2. No cross-module joins. Replace them with a call to the owning module's API, or with a read model the consuming module keeps up to date from events.
  3. Events for state changes, published in the same transaction as the change. The transactional outbox pattern does this: the service stores "the message in the database as part of the transaction that updates the business entities", and a separate process publishes it. Spring Modulith's event publication registry is one implementation.
  4. A database per service only when the module is extracted: "keep each microservice's persistent data private to that service and accessible only via its API" (microservices.io).
  5. Sagas for the few business transactions that genuinely span services, each step with a compensating action. If a design needs many sagas, the boundary is in the wrong place.

Reporting is the usual casualty: queries that joined twenty tables now need data from five services. Feed a reporting store or warehouse from change data capture or events instead of querying the services. Our strangler fig guide covers change data capture with Debezium and why dual writes from the application fail.

Extracting a service with strangler fig routing

Once a module is clean, extract it with the strangler fig pattern: put a routing layer in front of the monolith, build the service next to it, send a share of the module's traffic to the new service, and remove the code from the monolith when the service has taken all of it. Weighted routes in Spring Cloud Gateway let you start with a few percent of requests and compare results before moving the rest.

Extract one service at a time, and finish each extraction before starting the next. A migration with five half-extracted services carries the costs of both architectures. Our strangler fig pattern guide walks through the gateway configuration, branch by abstraction inside the monolith and the shared-database problem with Java examples.

A Java microservices stack in 2026

For Java microservices the defaults are well settled. The choices that need thought are about operations, not frameworks.

ConcernCommon choice in JavaNotes
Service frameworkSpring Boot 4.xSpring Boot 4.1 shipped in June 2026; every 3.x branch left open-source support on 30 June 2026 (migration guide)
Modules before servicesSpring ModulithModule verification, event publication registry, event externalization
Events between servicesApache KafkaKafka 4.0 (March 2025) was the first major release to run entirely without ZooKeeper (Apache Kafka)
ConcurrencyVirtual threadsBlocking code that scales for I/O-bound services (guide)
ObservabilityMicrometer with OpenTelemetry tracingA trace ID across every service call is the minimum before the second service goes live
ContractsConsumer-driven contract testsCatch a breaking API change in the provider's build, not in production
RuntimeKubernetes82% of container users ran it in production in 2025, up from 66% in 2023 (CNCF)

Team topology before service topology

Mel Conway described the constraint in 1968: an organization "will produce a design whose structure is a copy of the organization's communication structure" (Conway's law). A service architecture that does not match the teams will drift back toward the team structure, usually by accumulating cross-service calls.

DORA's research describes the target as teams that "can make large-scale changes to the design of their systems without the permission of somebody outside the team or depending on other teams" (DORA). In practice:

  • Every service has one owning team, which builds it, runs it and answers the pager for it.
  • A team owns a few services at most. Team Topologies puts the limit in terms of cognitive load: "overloaded teams make poor decisions and move slowly".
  • A platform team provides the pipelines, observability and runtime as a product, so service teams do not each rebuild them.
  • With one or two teams, the right number of services is usually one deployable with several modules.

Monolith to microservices migration checklist

  1. Write down the problem the migration solves: release contention, scaling, a runtime need. If none applies, stop here.
  2. Run the decision test above with evidence for each answer.
  3. Map the bounded contexts from language, change coupling, data ownership and team ownership.
  4. Restructure the code into modules and add a module verification test to CI.
  5. Move each table into its owning module's schema and remove cross-module joins.
  6. Replace synchronous cross-module calls with events where no answer is needed, using an outbox.
  7. Put tracing, per-module metrics and contract tests in place while it is still one deployable.
  8. Pick the first service to extract: few inbound dependencies, clear data ownership, a real scaling or release need.
  9. Route a small share of its traffic to the new service, compare results, then move the rest.
  10. Delete the old code from the monolith when the service carries all the traffic.
  11. Review after each extraction: did release coordination or scaling cost actually drop?

Stratoflow's microservices consulting starts with a fixed-fee architecture review: a target architecture and an order of work for your Java system, whether the answer is to split it or to modularize it and keep one deployable.