Rudder.io’s tranquil migration to Scala 3

Finally, we did it! Without Breaking Anything.

Three years ago, I wrote about why migrating Rudder to Scala 3 felt impossible for us. The costs were huge for an old code base like ours, with its very special long term maintenance constraints. Now, after two years, we completed the migration, and more importantly, we did it exactly the way I wished to make it happens: as a non-event, and that’s the thing I’m most proud of in this whole story. So I though it would be a good follow-up writing - and here we are.

We decided that the goal was really: migrate to Scala 3 without disruption.

Context: on-premise software with long maintenance cycles.

When you think about “migrating to Scala 3”, you can imagine porting your main branch to Scala 3, updating all dependencies, changing sources that are not correct anymore and correcting all the compiler errors. And done - even if it’s a big step. But for us, it couldn’t be that. We needed to do the whole things with very specific requirements and practices for our operations (how we develop rudder.io):

  • we ship on-premise artefacts, because it better fits our users’ habits and needs,
  • we have a time-based release cycle, with a cadence of a minor or major release every 6 months,
  • we maintain up to 5 branches in parallel (3 previous minors, current, and development one, i.e. next)
  • we correct bugs in the oldest maintained branch where they happen for that criticality and up-merge them. A security bug can be corrected in a 4-minors away code base and up-merged to next.
Rudder concurrent branch support and bug up-merge workflow]

So the cost of change in source code compatibility is much higher than for the typical micro-service that lives on main branch. On the other hand, we are running a 16 years old code base, and we are up-to-date with dependencies and languages, because we do know that it is a standard, good engineering practice that makes the whole thing sustainable.

So migrating to Scala 3 and migrating to Scala 3 without disruption are very different things, and confusing them is how our migration project could have become a death march.

Constraints: no disruption in our operational workflows.

This is why the shape of the cost scared me in 2023. There was a lot of unknown-unknown between scalac bugs, macro deprecation and dependencies. The source code changes made the project very hard to iterate, more like a forced big-bang switch. And of course, it was out of the question to “try and see”, and freeze the next release for an indeterminate time for a pure architectural project that generates zero business value on its own.

So before writing a single line of migration code, I spent time thinking about what constraints were actually non-negotiable for us:

  • The 6-month release cadence is sacred. In a far past, we paid a huge price for not respecting it. A migration task that doesn’t fit in a release cycle must be movable to the next one.
  • Every branch must always compile and pass CI. We can’t live in a broken intermediate state, because it would block the whole team. This is not specific to Scala 3 migration, just standard good practice that can’t be changed for the migration.
  • The product team mainly keeps working on the product. I didn’t want to pull people off features for months to work on something invisible to users.
  • The migration doesn’t disrupt the product team operations. The cost of changes due to migration must be as low as possible. Bug corrections and their up-merges need to remain simple; code in different branches must look similar (same shape, same idioms). This was the biggest challenge, and Scala 3 language evolution choices made it even harder.

Implication: it’s loooooong. But you get a free meal. With focus.

The consequence of accepting those constraints is that the migration takes longer. Much longer, in our case - it spanned releases 7.3 through 9.2, on about two and a half years. In my opinion, it was a fair cost to pay, since in our case, it was an advantage:

  • the Scala and tooling ecosystem was going to keep improving during that time, and every month we waited meant fewer problems to solve ourselves,
  • it will be an opportunity to learn from and talk with others doing the same thing, discovering unknown unknowns along the way,

And indeed, critical scalac bugs that were blocking for us in 2023 got fixed. People talked about their migration in Scala conferences. Scala 2.13 progressively tightened its Scala 3 compatibility mode, so each new 2.13.x version was helping in the migration process. IntelliJ got significantly better at handling the mixed-mode situation.

Still, be careful with these long-lasting migration projects. If you don’t want to transform them in a pile of never-finished, abandoned, Sisyphean work, you must:

  • continuously make them move - even if it’s a little;
  • at some point, you have to Just Finish Them. Once the unknowns are known, once the remaining effort is clearly mapped, once risk is correctly managed, and once you have the resources for it: just do it. It will bring joy and avoid the lassitude that such a long lasting TODO inevitably brings.

Iterate on tiny bits and make every step shippable in Scala 2

The loop we ran, over and over for two years, was conceptually simple even if the execution was tedious.

First, we chose to target the Scala 3 LTS branch. It was a stable target, allowing to actually progress toward a goal that wasn’t moving away as fast as we advanced.

Then, in parallel to any minor release branch for Rudder, we maintained a Scala 3 probe branch: same code, but with the compiler switched to Scala 3.3, so that we can identify what broke. Anything that broke and had a fix expressible in Scala 2 became a task that went into the next minor’s scope if it fit, or the one after if it didn’t. We never committed anything to a release branch that didn’t compile cleanly in Scala 2.

This meant the migration was, by construction, always iterable and always stoppable. We never reached a point of no return.

We heavily relied on Scala 3’s TASTy compatibility to have Scala 2.13 dependencies in a Scala 3 project, and on the -Xsource:3 scalac feature. I want to praise the Scala maintainers here, because without these two features, we couldn’t have engineered the migration as a non-risky project. And perhaps we wouldn’t have done it at all.

Team and people focus

I also want to be honest about what made this possible from a human perspective.

First, the Scala community, which was amazing. The Scala 3 migration even happened to make old frameworks like liftweb get some attention, and the maintainers of all these libs are doing a wonderful job. People, please remember that, and try to help, with time or money, especially in these complicated and uncertain time of magical AI fairies that ecosystem of humans that build make the infra we can rely on, durably.

Secondly, the Scala maintainers and VirtusLab were very helpful and always happy to help - even with complicated scalac bugs.

Third and most importantly, Matthieu Baechler, a freelance collaborator and friend external to the core team, did the majority of the grunt migration work like the millions of new type ascriptions, the construct rewrites, the library replacements. This was a deliberate choice. Keeping that work outside the core team meant the team could stay focused on the product. Given the method we had chosen, iterating on tiny bit, it also meant that change management happened gradually: instead of asking everyone to suddenly think in Scala 3, the codebase evolved incrementally under their feet, and by the time we switched the compiler, nothing was actually surprising to anyone. People had time to look at new constructs and were eager to see the migration ends to play with Scala 3 specific features.

I worked with Matthieu directly on the migration, which also helped. There was a shared understanding of where we were and where we were going, without it becoming a cross-team coordination tax.

3 years of migration, in 3 phases

Syntax in -Xsource:3

The first phase (Rudder 7.3 through 8.1) was largely mechanical: enabling -Xsource:3 and cleaning up the noise it surfaced. It was mainly early returns, missing type ascriptions, and the thousand small things that Scala 3 is stricter about. That’s the only part that we chose to do in 3 Rudder branches at once. We decided that it was cheaper to backport the source change one time and avoid grinding up-merge conflict for the next 12 months. Then, to ensure consistency between all branches, we relied on scalafmt’s magical feature, the Scala213Source3 dialect.

Dependencies & macro

The hardest phase was 8.2 and 8.3, where we had to deal with the non-happy-path dependencies. This is the one that can’t be trivially updated to Scala 3 like zio for example, or that we couldn’t keep in Scala 2 which was the case for liftweb. They fall mainly in 2 cases:

  • unmaintained dependencies with macros;
  • dependencies still in 2.13 themselves having conflicting dependencies ported to Scala 3.

For example, sealerate, which we used everywhere for sealed trait enumeration, was in the first case: it simply didn’t exist in Scala 3. We replaced it with enumeratum, which was a non-trivial refactor across a large codebase, a migration in the migration.

For cron4s, the situation was the second case: it was using a version of scala-parser-combinators incompatible with the one used in a Scala 2 dependency, so we contributed an alternative atto-based parser backend directly to the library (PR #601). Matthieu ended up becoming a maintainer of cron4s in the process, which feels like a fitting side effect of this kind of migration: contributing to the ecosystem you depend on rather than just consuming it.

Switch to Scala 3 and updating last Scala 2.13 dependencies

The switch itself happened for Rudder 9.0, October 2025. It was anticlimactic in the best possible way. We moved the compiler to Scala 3.3, then 3.7, with Liftweb still running as a Scala 2.13 dependency via the TASTy reader. We were deliberately conservative about using any Scala 3 features in that branch, to keep upmerges clean with the still-maintained Scala 2 branches.

The build went green. CI passed. We had migrated to Scala 3.

9.1 and 9.2 were about finishing the job on the dependency side: we engaged with the Lift maintainer on a Scala 3 migration path for Lift 4 (lift/framework#2008), and in parallel worked on replacing lift-json with zio-json — killing two birds with one stone, removing a Scala 2-only library while modernizing our JSON stack. This is also the time-frame where we really started benefiting from using Scala 3 idioms, especially given and opaque types.

With liftweb 4.0.0 in 9.2, the last Scala 2.13 dependencies are gone. After 16 years of Scala 2, Rudder is now a native Scala 3 project.

Happy end (that could have been easier to reach)

That long process confirmed that the migration costs to Scala 3 can be what you want to make them to be. In some cases, it clearly makes sense to just bite the pill and do a disruptive migration - why wait when your code has only one branch and switching to Scala 3 is an opportunity to clean old unmaintained dependencies ?

In our case, we were able to negotiate our approach, emphasising tranquillity. The ecosystem problems we feared in 2023 were real, but many of them were time-limited. The tooling problems got better. The library coverage improved. Some issues we had to solve ourselves, but in a way that produced contributions we’re proud of. Today, things are really mature enough, and any app project should be able to migrate (I won’t speak for Spark for which I don’t have any experience, but even there, it looks better than it used to be).

The Scala 3 language choices made our life hard. I still wish some choices were made differently, especially the narrative that Scala 3.0 was the last time a syntax change could happen ever, forcing a big-bang that could have been a peaceful river - and history since then proved it was false, for the better: language changes can and must happen without disruption, continuously.

But on the other hand, the Scala core team really did give us the tools we needed to be able to do an iterative migration. They did make a fantastic job with the -Xsource:3 Scala 2 mode, and above all with the TASTy compatibility that allows to use Scala 2 dependencies in Scala 3. Without that bit, we would have needed to be sure that liftweb would eventually migrate to Scala 3 - which was extremely unlikely in 2024, and so unlikely that it might have stopped our efforts to even try.

What didn’t change was the need to be deliberate about the migration strategy before writing the first line. The “no disruption” goal only worked because we defined it clearly up front and were willing to accept its main cost: patience. For a small team maintaining a long-lived product, we took a three years path of controlled, invisible migration over a risky big-bang, impacting everyone - business included - and with a clear death march ahead.

Finally, honestly, I’m glad it’s done. It was hard. It needed engineering, patience and long focus. But it’s done, without disruption, and I’m looking forward to actually using Scala 3’s full power now - sill without the meaningful spaces, though ;)