Skip to Content
Course content

235: Scala 2 vs Scala 3 Migration Considerations

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen a lot of teams approach the Scala 3 migration like they're painting a house—just slapping a new coat of paint over the existing walls and hoping the cracks underneath disappear. If you're moving a production codebase from 2.13 to 3, the most tempting path is the "naive" one: update your scalaVersion, run the compiler, and spend the next three days fixing red squiggles until it finally builds. While that gets you to a successful build, it often leaves you with a codebase that is technically Scala 3 but logically stuck in 2017.

The "Fix as You Go" Trap

The naive approach usually involves keeping all your implicit keywords exactly where they are. Since Scala 3 maintains a level of backward compatibility for implicit, your code will likely compile. But here is the problem: implicit in Scala 2 was a "Swiss Army knife" that did three different things: it defined implicit parameters, implicit values, and implicit conversions. This ambiguity is exactly what the Scala 3 team tried to kill by introducing given, using, and extension methods.

When you just "fix the errors," you miss the opportunity to decouple your logic. I once worked on a project where the team kept their old implicit conversions for a UserContext object. Because they didn't migrate to given, the compiler was still performing "magic" type coercions in the background that made the code nearly impossible to trace during debugging. You'd see a method call that didn't seem to match any signature, only to realize some obscure implicit conversion was firing five files away.

Replacing Hidden Magic with Explicit Givens

Let's look at a concrete example. Suppose you have a system that handles currency conversion. In the Scala 2 way, you might have written a "naive" implicit conversion to make your API feel "slick."

// The Scala 2 / Naive Scala 3 approach
implicit def intToMoney(amount: Int): Money = Money(amount, "USD")

def processPayment(m: Money) = println(s"Processing ${m.amount}")

// This works, but it's "magic" and dangerous
processPayment(100) 

In Scala 3, we want to be honest about what is happening. The better way is to separate the definition of the value from the extension of the type. We use given for the instance and extension for the functionality. If you really need that conversion, you should use a given conversion, but I'd argue you should probably just use an extension method or an explicit constructor instead.

// The Scala 3 Professional approach
case class Money(amount: Int, currency: String)

extension (i: Int)
  def toMoney: Money = Money(i, "USD")

def processPayment(m: Money) = println(s"Processing ${m.amount}")

// Now the intent is explicit. No more guessing why an Int became Money.
processPayment(100.toMoney)

The trade-off here is a tiny bit more typing for a massive gain in maintainability. When a new engineer joins your team, they don't have to hunt through the codebase to find out why processPayment(100) isn't throwing a type error; the .toMoney call tells them exactly what's happening.

Managing the Binary Compatibility Gap

One thing that often bites people during migration is the assumption that they can just mix-and-match libraries. Scala 2 and Scala 3 are not binary compatible. You cannot use a library compiled for 2.13 directly in a Scala 3 project unless that library was specifically published as a "cross-build" (usually denoted by _2.13 and _3 suffixes).

If you hit a wall with a library that hasn't been updated, don't try to force it. The "naive" move is to try and find some obscure compiler flag to make it work. The professional move is to create a "shim" module—a small Scala 2.13 project that wraps the legacy library and exposes a simplified API, which you then consume via a bridge or, more commonly, prioritize the migration of that specific dependency to a Scala 3 alternative. It sounds like more work upfront, but it prevents your dependency graph from becoming a house of cards.




📋 Practical Task

Refactoring a Legacy Session Context to Scala 3 Givens

You have been handed a legacy Scala 2.13 module that uses an implicit parameter to pass a UserSession through various service layers. The current code is "naive" because it relies on the ambiguous implicit keyword for both the definition and the usage.

Your Task: Refactor the following code to use Scala 3's given and using syntax. Remove the ambiguity of the implicit keyword to make the context propagation explicit.

// LEGACY CODE
case class UserSession(userId: String, token: String)

object SessionProvider {
  implicit val currentSession: UserSession = UserSession("user-123", "abc-789")
}

def fetchUserData(userId: String)(implicit session: UserSession) = {
  println(s"Fetching data for $userId using token ${session.token}")
}

// Usage
import SessionProvider._
fetchUserData("user-123")

Requirements:

  • Replace implicit val in the provider with given.
  • Replace (implicit session: UserSession) in the function signature with the using clause.
  • Ensure the code still compiles and executes the println statement.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.