Most enterprise Java monoliths are still running because they work. They process orders, calculate premiums, and move money every day. That's what makes modernizing them so risky. The system you want to replace is the one the business can least afford to break.
The big-bang rewrite is the most common way these efforts fail. A team spends eighteen months building a new system in parallel, the old system keeps changing underneath it, and the cutover weekend turns into a cutover quarter. This article lays out the approach I use instead: a sequence of small, verifiable, reversible steps, where the application stays in production and keeps shipping features throughout.
The core idea: modernization is a risk-management problem first and an architecture problem second. Every step should be small enough to verify, safe enough to roll back, and valuable enough to justify on its own.
Step 0: Ask whether you need microservices at all
"Move to microservices" is a means, not a goal. Before drawing any service boundaries, I want to know what problem the business is trying to solve. The usual answers are:
- Release speed. One change requires a full regression cycle and a coordinated deployment.
- Team autonomy. Several teams are stepping on each other in one codebase.
- Scaling. One part of the system needs far more capacity than the rest.
- Platform risk. An unsupported JDK, application server, or framework is a security and hiring liability.
- Cost. Licensing or infrastructure for the legacy platform is expensive.
Microservices help with the first three, but they come with real costs: distributed transactions, network failures, versioned contracts, more pipelines, and a much bigger operational surface. If the team doesn't already have solid CI/CD, automated testing, and production observability, splitting the application multiplies its problems instead of solving them.
Platform risk and cost often don't require microservices at all. An upgrade to a current Java LTS release and a supported framework, plus a move to containers, may deliver most of the value at a fraction of the risk.
| Signal | Leans toward |
|---|---|
| One or two teams, modest scale, release pain mostly from poor tests | Modular monolith |
| Many teams blocked on each other, clear business domains | Extract services along those domains |
| One hot path with very different scaling or availability needs | Extract that one capability |
| Little test automation, manual deployments, limited monitoring | Fix delivery first, then decide |
Step 1: Understand the system before changing it
Legacy systems are full of behavior nobody remembers deciding on. A short, focused assessment saves months later. I look at four things:
- The domain. Event storming or domain mapping sessions with business and engineering people to find natural bounded contexts such as billing, ordering, and customer.
- The code. Static dependency analysis (tools like jdeps, Structure101, or IDE dependency matrices) to see how tangled packages really are and where cycles live.
- Runtime behavior. APM traces and access logs show which endpoints and code paths carry real traffic, and which are dead.
- Change history. Git history reveals hotspots: files that change often and change together. Those clusters are strong hints about where boundaries should be.
The output is a prioritized map: candidate modules, how coupled each is to the rest, how much business value modernizing it unlocks, and what could go wrong. That map drives the order of everything that follows.
Step 2: Build a safety net
You can't safely change what you can't verify. Most monoliths have thin or outdated test suites, so the first investment is in tests that pin down current behavior, including behavior that looks wrong. Users and downstream systems may depend on those quirks.
Characterization tests (sometimes called golden master tests) are the workhorse here. Record real inputs and outputs, often sampled from production, and assert that the system keeps producing the same results:
class InvoiceTotalsCharacterizationTest {
private final LegacyInvoiceCalculator calculator = new LegacyInvoiceCalculator();
@ParameterizedTest
@MethodSource("recordedInvoices")
void totalsMatchRecordedBehavior(RecordedInvoice recorded) {
BigDecimal actual = calculator.total(recorded.input());
assertEquals(recorded.expectedTotal(), actual,
() -> "Behavior changed for invoice " + recorded.id());
}
static Stream<RecordedInvoice> recordedInvoices() {
return Fixtures.load("invoices-sampled-2026-09.json");
}
}
Alongside characterization tests, the safety net includes:
- API-level tests against the application's external interfaces. These survive internal refactoring, which is exactly what we're about to do.
- A CI/CD pipeline that builds, tests, and deploys the monolith automatically. If deployment is still a manual runbook, fix that now.
- Baseline metrics for latency, error rate, and throughput per endpoint, plus key business numbers. Later, "did the cutover work?" becomes a comparison against this baseline instead of a debate.
Step 3: Modularize in place
Before extracting anything over the network, make the boundaries real inside the monolith. Moving code into a separate deployable is easy. Untangling the dependencies is the hard part, and it's far cheaper to do in one codebase with one compiler and one transaction.
This is also the right time to bring the platform up to date: a current Java
LTS release, the javax.* to jakarta.* namespace migration,
and a supported version of Spring Boot or your application server.
Automated refactoring tools like OpenRewrite handle much of the mechanical
work, and the safety net from Step 2 catches what they miss.
Then reorganize packages around the domains from Step 1, give each module a small public API, and enforce the boundaries in the build so they can't quietly erode again. ArchUnit makes this a regular unit test:
@AnalyzeClasses(packages = "com.example.shop")
class ModuleBoundaryTest {
@ArchTest
static final ArchRule billingInternalsArePrivate =
noClasses()
.that().resideOutsideOfPackage("..billing..")
.should().dependOnClassesThat().resideInAPackage("..billing.internal..");
@ArchTest
static final ArchRule noCyclesBetweenModules =
slices().matching("com.example.shop.(*)..").should().beFreeOfCycles();
}
Spring Modulith and the Java Platform Module System offer similar guardrails. Pick one and make the build fail on violations.
A well-structured modular monolith on a current platform is a legitimate end state. Many teams find their release and maintainability problems are solved here, and they never need to pay the distributed-systems tax.
Step 4: Strangle incrementally
When a module does need to become its own service, I use the Strangler Fig pattern. Put a routing layer (an API gateway, reverse proxy, or load balancer) in front of the monolith. At first it sends everything to the legacy application. Then, one capability at a time, routes move to new services:
# Extracted capability goes to the new service
location /api/invoices/ {
proxy_pass http://invoice-service:8080;
}
# Everything else still goes to the monolith
location / {
proxy_pass http://legacy-app:8080;
}
A few rules keep this safe:
- Pick the first extraction carefully. Choose something with real value but low coupling. The first service exists as much to prove the pipeline, monitoring, and on-call process as to deliver features.
- Use an anti-corruption layer. Translate between the legacy model and the new service's model at the boundary, so legacy design decisions don't leak into new code.
- Define contracts explicitly. OpenAPI specs or consumer-driven contract tests (such as Pact or Spring Cloud Contract) catch breaking changes in the pipeline instead of in production.
- Keep the legacy path alive. Don't delete the old code until the new path has run in production long enough to earn trust.
Step 5: Release reversibly
Deployment and release should be separate decisions. New code can be in production for weeks before it serves a single real user. The techniques I lean on, roughly in order of increasing exposure:
- Feature flags to switch between legacy and new implementations without a redeploy.
- Shadow traffic or parallel runs. Both implementations process the same request, the legacy result is returned to the user, and the results are compared and logged.
- Canary releases that send a small percentage of real traffic to the new path, widening only while metrics stay within the baseline.
- Fast rollback. Flipping a flag or a route should take minutes and require no code change.
A parallel run can be as simple as this:
public Invoice calculate(Order order) {
Invoice legacy = legacyCalculator.calculate(order);
if (flags.isEnabled("invoice-service-shadow")) {
shadowExecutor.submit(() -> {
try {
Invoice modern = invoiceClient.calculate(order);
comparator.record(order.id(), legacy, modern);
} catch (Exception e) {
metrics.counter("invoice.shadow.errors").increment();
}
});
}
return legacy; // users still get the proven result
}
When the mismatch rate has been zero, or every mismatch is understood and accepted, across a meaningful window of real traffic, the cutover becomes a routine flag change instead of a high-stakes event.
Step 6: Untangle the data last
The shared database is usually the hardest coupling in a monolith, and the one teams most often try to split too early. I wait until the code boundaries have held up in production, then move data ownership in stages:
- Assign ownership on paper first. Each table gets exactly one owning module. Other modules read through that module's API or a database view, never by writing to its tables directly.
- Separate schemas in the same database. This surfaces hidden cross-module joins and foreign keys while the data is still in one place.
- Replicate, then switch. Change data capture (for example, Debezium) keeps the new service's datastore in sync while reads are moved over and verified.
- Avoid dual writes. Writing to two databases in one request will eventually leave them inconsistent. The transactional outbox pattern writes the business change and an event in the same local transaction, and a relay publishes the event reliably.
Cross-service consistency becomes eventual, so business processes that used to rely on a single ACID transaction need to be redesigned as sagas with compensating actions. That's a design conversation with the business, not only a technical change, which is another reason to save it for last.
Keeping the organization on board
Technical approach is only half of it. Modernization efforts also fail when they go quiet for a year and then ask for more time. A few habits help:
- Ship value every increment. Each step should improve something measurable: deployment frequency, lead time, incident count, or infrastructure cost.
- Keep delivering features. The business shouldn't have to pause its roadmap. Ideally new features are built in the new architecture from the start.
- Align teams with boundaries. Conway's Law works in both directions. Service boundaries that cut across team ownership tend to drift back together.
- Write decisions down. Lightweight architecture decision records explain why a boundary was drawn where it was, long after the people in the room have moved on.
Conclusion
Low-risk modernization isn't about finding a clever architecture. It's about sequencing: understand the system, protect it with tests and telemetry, clean up the boundaries in place, then extract and release in steps that can each be verified and undone. Done this way, the most dramatic thing about the migration is how uneventful each cutover turns out to be.
If you're planning a modernization effort or are stuck partway through one, I'd be glad to hear about it.
Back to Articles