The most expensive sentence in software is "let's just rewrite it." We've watched teams say it about a ten-year-old Java app, budget six months, and surface eighteen months later with a half-finished replacement, a frozen backlog, and users who are furious and still on the old system. The old system, by the way, is still running. It always is.
So we don't do big-bang rewrites. When a client comes to us with a creaking Spring or Java EE monolith, our default approach to legacy Java modernization is the strangler-fig pattern: incremental application modernization that pays down technical debt one slice at a time. Most of the interesting decisions happen before we write a single line of new code.
The strangler fig in three phases: wrap the monolith in a routing facade, peel off one capability at a time, and retire the old system only once the new services have carried real traffic.
Why the big-bang rewrite is a trap
A rewrite assumes you understand the old system. You don't. Nobody does. Somewhere there's an if (customer.getRegion().equals("EU-LEGACY")) branch that exists because of a contract signed in 2014, and the one person who knew why left in 2019. Rewrites quietly drop that knowledge on the floor. You find out which branches mattered when revenue dips and nobody can say why.
The strangler fig, named by Martin Fowler after the vine that grows around a host tree until the original rots away and the fig stands on its own, flips the model. You wrap the old app, route traffic through a facade, and peel off one capability at a time. The legacy code keeps serving everything you haven't touched yet. Each extraction is small, shippable, reversible. If a piece goes wrong, you flip the route back. Try doing that with a rewrite.
This is the core of how we handle legacy modernization and migration projects: incremental, behind a proxy, with the old system load-bearing until the new one earns its place.
Strangler fig pattern: deciding what to extract first
This is where teams get it wrong. The instinct is to start with the gnarliest module, the billing engine, the thing everyone's scared of. Don't. Your first extraction is a dress rehearsal for the whole monolith-to-microservices program, and you want it to succeed cleanly so the organization believes the approach works.
We score candidates on three things: how clear the boundary is, how much pain the module causes today, and how independent its data is. A module with a clean interface, real operational pain, and few foreign-key tangles into the rest of the schema is the one to pull first. High value, low blast radius.
Authentication tends to sit near the top of that list, which is why the project we point to most often is the auth service we built for USP.org (United States Pharmacopeia): a standalone, horizontally scalable microservice that now serves 500,000+ users. Auth is a good first target for reasons that aren't obvious until you've done it. The contract is well understood: issue a token, validate a token, refresh it. Design it right and it's stateless, so horizontal scaling is mostly a matter of running more instances behind a load balancer instead of fighting shared session state. And it's a seam every other service has to cross. Once you control auth, you control a chokepoint you can use to route, observe, and gradually redirect everything else.
There's always friction. A legacy app that stores sessions in memory with sticky load-balancer affinity is doing the exact thing that stops you from scaling out. Moving to stateless tokens means every downstream call that used to trust an in-process session now has to validate on its own, and you run both schemes in parallel during the cutover so nobody gets logged out mid-migration. That dual-running period is uncomfortable. It's also the price of not having a bad weekend.
The Spring Boot upgrade path: Java EE and the javax-to-jakarta migration
A lot of "legacy Java" is just Spring that nobody dared to upgrade. If you're sitting on Spring Boot 2.x, the jump to 3.x is the big one, because that's where the javax-to-jakarta migration lands: the javax.* to jakarta.* namespace change. It's tedious and it touches everything: JPA annotations, servlet filters, validation. A full Java EE to Spring Boot migration is the same story at larger scale. Don't do that upgrade and a feature change in the same pull request. Separate them. One PR that only moves you across the Jakarta boundary, green build, deployed, and then you build features on the new baseline.
A few things that have bitten us, so they don't bite you:
- The Java baseline moved. Spring Boot 3 needs Java 17 minimum, so a Spring Boot upgrade is often secretly a Java 17 (or Java 21 LTS) migration too, and that drags in its own surprises around reflection and module access.
- Third-party libraries lag the namespace change. You'll hit a dependency that still imports
javaxand hasn't shipped a Jakarta release, and you're stuck waiting or forking. (Tools like OpenRewrite automate a lot of the mechanical edits, but not this.) - Security config got rewritten between versions. The
WebSecurityConfigurerAdapteryou've been extending is gone, replaced by theSecurityFilterChainbean style, and a copy-pasted migration here is how you accidentally leave an endpoint open.
Run mvn dependency:tree before you start, and actually read it. Half the pain is transitive dependencies you didn't know you had.
Data is the hard part
The hardest part of any extraction isn't the code. It's the data. When you pull auth out of a monolith, the user records usually sit in tables the rest of the system joins against freely. You can't just lift those tables into a new service and leave dangling foreign keys behind.
The pattern we lean on: the new service owns its data, and the old system talks to it through an API instead of a JOIN. Where a hard cut isn't safe yet, we run change-data-capture or a sync job so both sides stay consistent during the transition, then retire the sync once nothing reads the old table. Yes, it's more moving parts than a rewrite. But each part is small enough to reason about, and you're never more than one revert away from the last known-good state.
Auth seams have a specific failure mode worth naming. The moment you split auth into its own service, you've put a network hop on the hottest path in your application. Every request now depends on it. If that service is slow or down, your entire product is down, in a way it wasn't when auth was an in-process call. So token validation has to be cheap. We cache public keys and verify signatures locally instead of calling back to the auth service on every request, and the service needs real redundancy from day one. Horizontal scalability isn't a luxury here. It's the difference between a contained incident and a total outage.
Keeping the lights on during the migration
The whole point of incremental modernization is that production never stops working. That means feature flags on every route change, so you can dial traffic from 100% legacy to 100% new in increments and watch the dashboards as you go. It means the old code path stays live and tested until the new one has carried real load long enough that you trust it, not until the demo passed. And it means you measure before and after on the same metrics, because "it feels faster" is not a migration sign-off.
We've also built systems from scratch where we got to choose everything, like the hospitality automation platform behind Bar.Stream, now serving 200+ B2B customers. Modernization is harder than greenfield, and anyone who tells you otherwise hasn't done enough of it. You're operating on a patient who refuses to lie down. The constraint isn't your cleverness. It's that the business cannot pause.
That constraint is the whole argument for the strangler fig. A rewrite asks the business to bet everything on a single switch-flip months out. Incremental extraction asks for a series of small, hedged bets, each one validated in production before the next begins. We've watched the first approach burn budgets. We've shipped the second one, auth service and all, to half a million users without a midnight cutover. We know which one we'd put our own money on.