Changing a web framework must be done the same way you would change an aircraft engine midair. You do it prudently, in small, safe steps all the way. You test everything so no surprise surface when you do the switch.

I moved LogEze to Javalin. It is a product I have built since 2014, and it ran on the Spark Java web framework until recently. The cutover was one environment variable. I flipped it and everything switched from Spark to Javalin at once. Most of the work was in the preparations. Two weeks of them were hands-on work, abstracting Spark away from the core of the app far enough that a parallel Javalin implementation could be built alongside it and switched on safely. The rest started a lot earlier, with spikes to settle on the replacement and a shared super class that every controller already ran through, in place more than two years before I needed it.

There was one move I could not make, the one I have leaned on for twenty years. I could not strangle the old framework. Usually, when I replace something big, I run the old and the new side by side and move traffic across one piece at a time. That was off the table from the start. Why it was off the table tells you something about which migrations allow a gradual cutover and which do not.

It is a long post. If you are about to do a framework migration yourself, the details are the point.

Why move at all

Spark Java is abandoned. The last release is 2.9.4, published in July 2022. The last real commit bumped Jetty and the one after it edited the README, and there has been nothing since. Four years without an update is enough reason on its own. I do not want the framework at the front door of a commercial product to be code nobody is looking after.

Javalin is the natural landing spot. It is small, it is actively maintained, and it uses Jackson for JSON out of the box. Nothing exotic, which is what I want from a web framework.

A safety net first

The plan was straightforward. Push all the Spark-specific code out of the controllers and into a shared super class, so the controllers stopped caring which framework they ran on. Then teach that super class to speak Javalin as well as Spark, and cover every controller with tests before flipping anything.

Most of the migration went into this part rather than into the port. Pulling the common code up into the super class was safe enough on its own. The controller tests already covered the behaviour, and while the code moved they ran against both the new super class and the controllers that still bypassed it. But none of them aimed at the super class directly. They reached it from the outside. That is fine until you have to rebuild the one class the tests only reach indirectly.

The controllers were built the way I build all new code, test first, so most of them had a good safety net already. Some of those tests had been used to drive the shared super class, which meant they spoke a bit too much Spark. Most of them were recycled to target the super class instead of a specific controller.

So the first days went to characterization tests written straight at the base controller: the download path, the exception funnel, header handling, the overload dispatch, and the observability accessors. All of them written before I changed a line of behaviour. Then the same treatment for the file-upload base class. It is boring, careful work, and it is what made everything after it safe.

These tests were the other kind. The behaviour existed and was already covered, just not by a test aimed at the seam I was about to move. I did not want to change it, I wanted to hold it still while I moved the framework underneath it. Characterization tests are for exactly that. They describe what the code does now, not what it should do, and they fail as soon as you change it by accident.

You can't strangle a web framework

The obvious plan was to keep Spark as master, stand Javalin up beside it, and move routes across one at a time. Keep the old framework serving most traffic, cut over a handful of routes to the new one, watch them, cut over a few more. This is the strangler fig pattern. I first read Martin Fowler's description of it in 2004, and it shaped how I approached migrations for the next twenty years. He rewrote it in 2024, and that updated version is the one to read. The move is the same either way: put a shim in front of the old thing and move calls across gradually until the old thing is dead.

It would have been a lot of work, but not difficult. It was never an option, for a very boring reason. Two web servers cannot listen on the same port at once, and Heroku, where LogEze runs, gives you one open port per backend. Without a second port there is no side-by-side run, and without a side-by-side run there is no gradual strangulation at the HTTP layer.

That constraint explains when you can strangle and when you cannot. The strangler fig works when you control the chokepoint, the single place the old thing gets invoked. If you own that spot, you can put your own shim in front and route calls across one at a time.

A database swap is the easy case. Oracle to Postgres: you own the invocation, so you run both stores, dual-write, backfill, and cut reads over table by table. Same for a queue, a cache, or a third-party API behind your own client. If you control the chokepoint, you can strangle.

A web framework is the opposite case, at least when you do not control the runtime it is deployed to. It sits at the entry point where there is exactly one door, the port, and you do not get to put your own shim in front of it. The framework is the shim, so there is nothing to slide a strangler in front of. Instead of strangling, I built a parallel Javalin world inside the same process, kept Spark as master, and threw the switch. That is more work and more risk than a database migration of the same size.

Control the runtime and it turns back into the easy case. Run the two versions side by side and write your own layer in front of them, a proxy or a load balancer you own, and the chokepoint is yours again. Route all traffic through it to both, compare the outgoing messages, and when they always match you can start trusting the replacement. Then you cut over with no downtime at all. Simple, but not easy. I did not have that, which is the whole reason the strangle-versus-bang question came up in the first place.

The general rule I took from this is that some migrations let you strangle and some force a big bang. The deciding factor is whether the thing you are replacing sits behind a chokepoint you control.

A reversible big bang

If you cannot strangle, you cut over all at once. A big bang sounds risky. This one was not, because it could be undone.

The cutover was one environment variable. Set it one way, the process wired up Spark. Set it the other way, it wired up Javalin. Rolling back was the same single move, flip the variable back. A cutover you can undo in seconds is a very different thing from one you have to unwind by hand. The risky part was not the flip. It was the weeks of work before it, which is where the safety net was built.

The same thinking applied all the way through, not just to the flip. Every step was a baby step, small enough that if it broke, I knew exactly what broke, and I could get back to a known good state fast. I have seen the opposite at a customer site. It ran up a month-long outage of a shared cache and a five-figure invoice due to excessive calls to a mainframe. That is a story for another post. LogEze went one reversible step at a time and never got close to that.

The controller that got smaller

The shared abstract controller that made the port possible was not a migration invention. It was born in early 2024, in a commit whose message reads "An experimental controller with the goal to reduce the need for boilerplate code when identifying a user and handling errors". It landed under a package literally named experimental, under 200 lines, next to a throwaway books domain I was using to try the idea out. It moved to its real home a week later.

So it predates the migration by more than two years, and the word "experimental" in that first package name says something about how it came to be. I did not build it for a framework migration. I built it to stop copying error-handling boilerplate between controllers.

It grew the way these things do. Under 200 lines at birth, about 350 by the end of 2024, about 500 by the end of 2025, close to 600 by the morning the port started. Ninety commits over those two years, seventy-five of them before the migration. Today just shy of 350 controllers extend it.

It is also where everything security-related lives. Authentication, authorization, and security logging all happen in that one class. One place to reason about auth and audit, one place to port to Javalin. That is a large part of why the switch was tractable at all.

It is also the only file that got smaller during the migration. Close to 600 lines down to just over 500, because the two framework entry methods collapsed into one generic flow.

Run your routes through a second implementation

I used a coding assistant to generate a happy-path test for every controller. The original motivation was small: I wanted to smoke out shadowed routes, two handlers fighting over the same path. LogEze has around 350 routes, and every one of them got its own reachability test in a single day. The next day those tests were upgraded from "responds at all" to real happy-path assertions.

That upgrade exposed bugs. Eleven defects surfaced that had been broken under Spark the whole time. The migration did not cause them. They came out because the same routes were asked to return a real 2xx with a real service graph behind them, which the Spark suite had never done.

Three of them are worth telling. All three came out on the same day, and not from the tests. They fell out of writing the Javalin route for each one, because you cannot port a route without reading what it actually does. Two were plain 500s on edge cases nobody had ever exercised. The third one was more serious. Four password routes wrote no security audit log at all, in a system where the audit trail is the point.

Two more are worth including because they look so silly written down. One was a path.toLowerCase() with no locale. The no-argument version uses whatever default locale the JVM happens to start with, and Turkish is the one that breaks it. Turkish has two separate letters where English has one. A dotted i, which uppercases to İ, and a dotless ı, which uppercases to I. So under a Turkish default locale, lowercasing an uppercase I gives you ı and not i. An uppercase I anywhere in a redirect target comes back as a path that no longer matches, and /signup stops resolving. The Turkish I is a well known trap, and it was still sitting in my own redirect controller. The other was a file download error branch that wrote no security log, because one method logged and the method it delegated to did not. That one was found by collapsing two duplicated paths into one, which is its own small argument for not having two paths.

Here is the full list. I think it supports a general claim: running the same routes through a second implementation is a bug-finding technique of its own, whatever the second implementation happens to be. All of them except the last had been broken in production under Spark.

I fixed eight more things in the same window, but those were Javalin-path only and had never been broken in production.

The test doubles were lying

One in-memory repository, the one that stands in for user recovery codes, had two separate defects. The first was a plain map.get(userName) followed by iterating the result, so a user with no recovery codes got a null pointer exception where the real SQL repository returns 0 and an empty list. A getOrDefault fixed it. I wrote about not returning null in 2011. The message had not reached this double.

The second one, found fifteen minutes later, is worth quoting:

- if(recoveryCode.usedAt() != null
-         && recoveryCode.usedAt().usedAt() != null) {
+ if (recoveryCode.usedAt() == null) {
      activeCodes++;

It counted used codes and reported them as active. The condition was inverted, and it had been that way for just under eight months. The reason it stayed hidden is simple. Every fixture had an equal number of used and unused codes, and five used out of ten returns 5 whichever half you count. The bug only showed up when a contract test used an asymmetric fixture. Symmetric test data hides inverted conditions, and symmetric test data is exactly what a person writes when setting up a fixture by hand.

A second double, the in-memory file store, was broken in a duller way. Two of its methods were throw new RuntimeException("not yet implemented"), and its hasFile only consulted the binary map. Half an interface, unimplemented, for three months and three weeks. No test ever noticed, because no test ever asked the double to do the half it could not do.

All three were caught by a contract test, and all three had been sitting inside one the whole time. The fixtures were the weak part. Symmetric counts made the inverted condition invisible, and nothing in the suite called the two file store methods that were stubbed out. A contract test is only as strong as the data you feed it.

One contract test run against both implementations is a pattern I always use for repositories, and sometimes for other things. I wrote about it in Fast and accurate: testing the same contract two ways. The idea is older than that post. In 2011 I test drove a database layer by writing an in-memory implementation first and then a JPA one against the same tests. Back then the in-memory version was a stepping stone, thrown away once the real one worked. In LogEze both stay, and the contract has to be kept up for as long as they both live.

While we were in there: Gson to Jackson

Javalin uses Jackson natively, so running the same JSON library everywhere was the obvious call. Gson came out in a one-line change to the build file. The same change also deleted an exclude group: 'com.google.code.gson' block that had been wrapped around a test dependency for years. Removing our own Gson let that dependency pull its own transitively, and the exclusion became dead weight.

Before any of that, the serialization was reimplemented on a Jackson mapper configured to match Gson byte for byte, with the deserializer characterized before anyone touched it. That is the boring work that stops a JSON library swap from changing your wire format without anyone noticing.

A war story: a redirect loop in production

The switch looked ready and then it broke. Two redirect defects, fixed in two commits three hours and fifty-four minutes apart, at 08:21 and 12:15.

The first was an OIDC callback route that answered 200 with a JSON body where it should have answered 302. The redirect() call set the location header, and then the funnel carried on regardless, rendered a response, and set the status straight over the top of it. The fix was a flag that short-circuits the funnel once a redirect has been issued:

     public void redirect(String url) {
+        redirected = true;
         response.redirect(url);
     }

and, in the funnel, before rendering:

+            if (redirected) {
+                setResponseHeaders(response);
+                recordResponseTime(start);
+                return shortCircuit.get();
+            }

The second one was worse, and the cause was not what I would have guessed. A before-filter upgrades plain http to https:

if ("http".equals(scheme) && hostIsPresent && !local) {
    String httpsSite = "https://" + host + path;
    response.redirect(httpsSite);

Behind the proxy the request arrives as plain http with an X-Forwarded-Proto: https header. Spark's embedded Jetty was configured to honour that header. A bare Javalin.create() is not, so scheme() returned http forever, the response redirected to a URL the client was already on, the client tried again, and around it went.

The fix is not in the filter. The filter was correct. The fix is Jetty server configuration, a ForwardedRequestCustomizer on the HttpConfiguration, which is exactly what Spark had been doing for us invisibly for years:

    private static Server forwardedAwareServer(int port) {
        HttpConfiguration httpConfiguration = new HttpConfiguration();
        ForwardedRequestCustomizer forwardedRequestCustomizer =
                new ForwardedRequestCustomizer();
        httpConfiguration.addCustomizer(forwardedRequestCustomizer);

        LoomThreadPool threadPool = new LoomThreadPool();
        Server server = new Server(threadPool);
        ...

That LoomThreadPool line matters. The moment you hand-roll the Server, you throw away the virtual-thread pool Javalin would have given you, so you have to put it back. Handing the framework a server instead of letting it build one means inheriting every default it was quietly setting. The test that came with the fix asserts Thread.currentThread().isVirtual() on a probe route, which is the only way that regression stays fixed.

This was not really a redirect bug. It was a bug about defaults the old framework had been setting for us without anyone writing them down. I expect every framework migration has a version of this. It will probably not be ForwardedRequestCustomizer, but it will be something the old framework did for free.

Two outages, a few minutes each

Because I could not do the no-downtime cutover, the switch cost real downtime on two separate occasions. Uptime Robot logged them at 09:56 to 09:57 and 11:20 to 11:24 on the same day. Part of the second one was a red herring: a third-party file service was broken at the same time and threw me off the scent for a while. The real cause of both was the http-to-https redirect loop.

The status-code fix, the OIDC callback answering 200 instead of 302, landed at 08:21. The ForwardedRequestCustomizer that fixed the redirect loop landed at 12:15. Both outages sit between them. That fits what happened: cut over to Javalin, the redirect loop hits real users, flip the variable back, work out what is wrong, try again, get bitten again, then find the real cause and land it for good. The one-minute outage and the four-minute outage are the two attempts. Being able to flip the variable back is what kept them at minutes instead of hours.

They were stressful minutes while they lasted, watching production be down and chasing the wrong service. I would rather report that honestly than pretend the switch was seamless. It also makes the case for the parallel-instances setup I did not have. With a chokepoint in front and response comparison, both outages could have been avoided.

You can't just jump to the latest Javalin

I pinned Javalin 4.6.8. The commit message says why: "the last version that coexists with Spark on Jetty 9.4".

Spark 2.9.4 asks for jetty-server 9.4.48, Javalin 4.6.8 asks for 9.4.51, and Gradle resolves that to 9.4.51 by conflict resolution. Both sit inside 9.4.x, which is the only reason they coexist in the same process. Javalin 5 moved to Jetty 11 and the jakarta.* servlet namespace. With Spark still on the classpath, a jump to Javalin 5 is not a version bump, it is two frameworks demanding two incompatible servlet APIs in one JVM. Migrate onto a compatible version first, chase the newest version later.

The migration isn't finished

The build still reads io.javalin:javalin:4.6.8 today. I never did the second upgrade. We are still sitting on the pinned compatible version, and the real cleanup step is not the Javalin upgrade at all. It is deleting Spark. Deleting Spark is what frees us to go to Javalin 5 and Jetty 11.

So the migration is reversible, and the price of keeping it reversible is being stuck on a two-year-old Javalin until we commit. The strangler-fig literature rarely mentions this. The parallel period has a running cost, and somebody has to decide when to stop paying it.

The numbers

The whole thing took about two weeks of real work. Around 130 commits over 13 working days. Roughly 22,000 lines added and 11,000 removed, so net growth of about 11,000 lines, most of it the parallel Javalin routing, the extracted service provider and its in-memory twin, and the route test classes. About 700 test methods added and 140 deleted.

One number stands out. Of those commits, only about 20 actually port anything. Everything before the cutover day is making the code portable, and everything after it is cleaning up what the port exposed. The migration proper is a sixth of the work. This is what people get wrong when they plan a framework migration. They budget for the port and are surprised by the rest.

A couple of days show how poor a proxy commit count is for progress. One day put every route test into 4 commits. Another day spent 13 commits and added zero test methods, because it was rewriting the assertions inside tests that already existed, so the serializer swap underneath them could not hide a regression.

And one day deleted more than a hundred test methods on purpose, the biggest deletion day of the whole migration. Once nine groups of controller tests drove the typed handler directly instead of stubbing Spark request objects, all those per-controller copies of the same funnel assertions became redundant. Deleting tests during a migration feels wrong, but it was the right thing to do here. A test that only repeats what nine other tests already assert is not a safety net. It is noise you have to maintain.

Day by day

Five days of making the code portable, two days of porting, three days of cleanup, and the cutover sitting almost invisibly inside one day as a single environment-variable switch.

Day Commits What happened
1 12 Safety net first. Characterization tests pin the base controller.
2 5 Same treatment for the file-upload base class. First payloads become records.
3 7 Wire format. Serialization reimplemented on Jackson to match Gson byte for byte.
4 13 Controller tests assert on the JSON directly instead of round-tripping it.
5 7 Gson removed. The two entry methods start collapsing into one shared path.
6 3 Funnel convergence finished. Pure refactoring, carried by the earlier tests.
7 17 Biggest deletion day. More than a hundred redundant funnel-assertion copies removed.
8 13 The runner-neutral request and response seam, plus the first Javalin adapters.
9 16 The env-var runner switch, the first Javalin route, all non-blocking routes, four filters.
10 20 The awkward parts. CORS preflight, multipart uploads with a 413 pre-check, redirect fixes.
11 7 Verification day. Every route gets a reachability test, grouped by domain.
12 9 The fallout. Route tests upgraded to real assertions, surfacing the production defects.
13 2 Two redirect defects fixed. The status clobber and the https loop.

The days that stand out in that table are day 7, when the redundant funnel assertions came out, and days 9 and 10, when the routes were ported. None of them is where the work was. The expensive thinking is the small hand-written characterization work early on. The mechanical days, one test per service helper and one test per route, carry most of the test volume, and those are the days a coding assistant is really good at. The assistant did all the typing. I did the steering and the babysitting.

Why it was fast, and where it would not be

Two weeks is not magic, and I do not want anyone to read it that way. That number rests on years of groundwork. LogEze had good test coverage from the start, not bolted on for the migration. It has a solid continuous deployment pipeline already in place. And I am the principal architect. I have built this application since 2014, so I know what it does and where the bodies are buried. On top of that I keep working to keep it simple to maintain. Two weeks on a codebase I know well and have deliberately kept healthy is not the same as two weeks on a neglected system someone handed me last month.

Doing something similar elsewhere could be a lot harder. Without proper test coverage you have no safety net and have to build one first. Without a deployment pipeline every cutover is manual and risky. On a system carrying a lot of technical debt, most of the time goes to discovering what the code actually does before you can change anything. The same migration on a codebase like that is not two weeks, and pretending otherwise would be dishonest.

For one of those two weeks I was on tour with an orchestra and worked from the tour bus. A phone network was all my coding assistant and I needed to keep moving. With a good safety net, a migration this size does not demand a war room and a wall of monitors.

Conclusion

Before you plan a migration, find out whether the thing you are replacing sits behind a single point you control. A database, a queue, a cache, an external API behind your own client: you control the invocation, so you can strangle. A web framework sits at the one door into the process, the port, and unless your runtime lets you put a proxy in front, you cannot. That decides whether you get a gradual strangulation or a big bang.

If it forces a big bang, make the big bang reversible. One switch to flip and one switch to flip back. Keep the risk in the weeks of preparation, where you have tests to catch it, and not in the cutover. And accept that staying reversible has a running cost you pay until you delete the fallback.

Then run your routes through the new implementation with real assertions and a real service graph behind them. You will find bugs that have been sitting in production for months. Not because the new framework caused them, but because a second implementation asks questions the first one never did.

Resources