A 2025 Scala stack for the functionally inclined
I recently started a new Scala project, one which is greenfield. Building something new from the ground up poses the need - and opportunity - to make technology choices.
Scala is a language where there’s usually many ways to achieving a goal, and the same can be said for Scala libraries. This is a good thing because it gives options, and somewhat daunting, because it’s time-consuming to arrive at a set where all the pieces are good, and work well when put together.
Below we present an opinion piece of what a Scala stack can look like in 2025, if your favourite paradigm happens to be purely functional programming. Almost all of the options presented are not novel, and I’ve used them in services which are in production.
This is also an Ode to all the people who quietly spent countless hours to bring us - for free - all the amazing open-source libraries we use (and take for granted!) every day. Thank you!
Language version
Prior to this exercise I hand’t done any Scala 3, personally or professionally. I’ve been reading and writing code for a while now, so I figured picking up the new language version itself wouldn’t be too much of a hassle. I was more worried whether all my libraries of choice would work with Scala 3, and in 2025 they all do, with a couple of exceptions I’ll note in subsequent paragraphs. Those usually have adequate replacements, so for new services, I’d recommend defaulting to Scala 3.
The reason I recommend Scala 3 for greenfield, service (not library) development is
- For application / service programming, it appears to be at least a marginally better language than Scala 2. It’s definitely not a worse one.
- It’s going to take a somewhat experienced Scala 2 developer 5-20 hours to get started with Scala 3, which is negligible in the long run
- You’d probably be introducing future technical debt in your new project if you choose Scala 2 now.
This recommendation comes with the huge caveat that I don’t have Scala 3 experience with an extremely large codebase. I’ve previously worked on a project that took an hour to compile, and I cannot say if that hour is going to become 50 minutes or 3 hours if they made the switch.
Language changes
I didn’t find picking up language changes hard. My current personal and work projects try to stick to a relatively small subset of Scala that’s purely functional, and all compile with -Xsource:3
, so the transition was mostly painless. Here’s what I gradually did while building my new project.
- Pick up new control flow syntax - not mandatory (with Scala
3.3.4
) and can be automated, as described later. This mostly boiled down to writingif a then foo else bar
in place ofif (a) foo else bar
- Stop writing optional braces - not mandatory and can be automated, as described later.
- Use
given / using
in place ofimplicit
- Use
extension
in place ofimplicit class
- Use
enum
in place ofsealed trait
whenever describing sum types. If my understanding is correct, this mostly works, however they currently don’t have 100% feature parity. Specifically enums cannot nest enums. You can revert tosealed trait
whenever facing issues, granted that’s not great for consistency.
The above is, of course, by no means an exhausting list of language changes and new language features in Scala 3. Specifically it leaves out any new and changed type-level and metaprogramming facilities. We’re mostly focused on slightly boring application programming in this article.
Cross-building own libraries
You might be in the position of maintaining libraries that need to be used across Scala 2 and Scala 3 services.
One way to solve this, if feasible for the code in question, is to code your libraries to a subset of Scala 2 that’s also valid Scala 3. For me, this boiled down to "-Xsource:3-cross"
, or, more completely:
libraryDependencies ++= {
CrossVersion.partialVersion(scalaVersion.value) match {
case Some((2, n)) =>
List(
compilerPlugin("org.typelevel" % "kind-projector" % "0.13.3" cross CrossVersion.full)
)
case _ =>
Nil
}
}
ThisBuild / scalacOptions ++= {
CrossVersion.partialVersion(scalaVersion.value) match {
case Some((2, 12 | 13)) => Seq("-Xsource:3-cross", "-P:kind-projector:underscore-placeholders")
case _ => Nil
}
}
If such a simplistic and limited approach does not work for you, take inspiration from established open source projects.
Enforcing code style
sbt-tpolecat
This turns on a number of useful compiler warnings, and by default, turns warnings to errors.
addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.2")
scalafmt
I find myself being inconsistent with regards to optional brace usage and new control flow syntax usage. The bigger the squad, the bigger the inconsistencies will be. You can enforce this via tooling. In my .scalafmt.conf
, I’ve settled for
version = 3.8.3
maxColumn = 120
runner.dialect = scala3
rewrite.scala3.convertToNewSyntax = true
rewrite.scala3.removeOptionalBraces = true
scalafix
I find myself faffing with imports a lot. Let tooling sort that instead. For example,
rules = [
OrganizeImports
RedundantSyntax
]
OrganizeImports.targetDialect = Scala3
OrganizeImports.removeUnused = false
OrganizeImports.groupedImports = Merge
Core abstractions and effect system
Core abstractions buy us the capability to not repeat ourselves, and effect systems help us increase program correctness, build resource-safe and cancellation-safe code, and write parallel and concurrent programs in a concise and safe manner.
For core data types and abstractions, I’ve went with cats
, and its companion cats-effect
as an effect system.
The other notable alternative is zio
, which provides core abstractions and an effect system in one bundle. My recommendation is to use whatever you and your team are most productive with. If you are not yet invested in either, your evaluation must take into account whether all libraries you need exist / work well with your choice - lest you need something niche which exists in only one of the ecosystems.
I imagine in 99% of cases you’d be fine with either.
Stream programming
cats-effect
-based projects have settled on fs2
. It’s mature, feature-rich, has an amazing community on Discord, and libraries for many technologies you may need exist that are built on top of it. That last part is extremely handy, because it makes your whole stack compose together well, when individual pieces are picked with care.
Testing
If you use an effect system, you want a test framework which is integrated with it. The alternative would be using a side-effecting test framework such as ScalaTest
, but that forces you to write bridge code between pure expression land (your code under test) and side effect land - think lots of allocated
and unsafeRunSync
. This becomes very unwieldy very fast, and is easy to get wrong. It’s not a great option.
I’ve settled for weaver-test
, since it’s based on fs2
and composes nicely with the rest of the stack. It works well and I’ve used it on a number of projects.
In addition I use a micro-library I’m the author of, weaver-test-extra
, to aid in purely functional test setup and teardown. (See the repo readme for reasoning, use and limitations).
weaver-test
is in the process of being transferred from disneystreaming
to typelevel
. I expect this to be a good thing, as it might receive more traction and more maintenance.
Integration testing
I.e. spinning up docker services.
Use testcontainers-scala. Use cats.effect.Resource.fromAutoCloseable
to view a container as a Resource
, which will let you achieve resource-safe setup and teardown.
Property testing and random data generation
Use scalacheck
. weaver-test
provides scalacheck
integration out of the box.
If instead of a property test, you need an ad-hoc piece of random data for a regular test,
extension [A](gen: Gen[A])
def sampleF[F[_]: Sync]: F[A] = Sync[F].delay(gen.sample.get)
def sampleIO: IO[A] = sampleF[IO]
Deriving Gen
and others
Note at the time of writing magnolify-scalacheck
does not yet work with Scala 3. You might have to figure out your own way to derive scalacheck
instances, or write them out manually. I don’t currently have a solution to offer you.
Logging
It’s best to use a logging library which integrates with your effect system, and for sure you want to avoid ones that are bloated to the point they cause severe security issues.
valskalla/odin
used to be a great alternative, but it’s no longer maintained and has no Scala 3 version, so use the maintained fork scalafreaks/odin instead.
odin
is purely functional, integrated with cats-effect
and can provide principled MDC context propagation via ReaderT
, if you need that. In addition, it’s not just a purely functional wrapper, but has an slf4j
implementation, which foregoes the need for logback
or anything else.
As good practice dictates, I recommend to log to console only, and if logs need to go anywhere else, let external infrastructure handle that.
package yourproject
import cats.effect.*
import io.odin.*
package object log:
def defaultLogger[F[_]: Sync]: Logger[F] = consoleLogger[F](minLevel = Level.Info)
An alternative to odin
is woof
. The main difference is woof
currently uses IOLocal
for context propagation, whereas odin
allows for a ReaderT
/ Ask
implementation. If you don’t have strong opinions about this, or if you have no need for context propagation, they can be viewed as mostly similar.
Observability / distributed tracing
Observability means that while executing our program in production, we collect and persist data about the execution in a structured manner, so that we can answer questions about how the program is performing when things go awry.
We’re able to answer questions such as
- What was the user id of that request that ended with a 5xx
- Where was time spent for the 1% slowest requests in the last hour
- What’s the latency distribution of X in this deployment versus the previous deployment
, without having to resort to hopeless log grepping and println
s.
If you’re not already doing that, you should! It makes your life operating software in production a lot easier, and you end up with a better overall product.
My current library of choice is natchez, because that’s what my database library of choice currently uses. As an actual observability backend, I default to Honeycomb. No strong opinions here - I just find it pretty easy to work with, and the pricing feels reasonable. natchez
has backend implementations for other popular platforms, including Datadog and AWS X-Ray.
Library-wise, the landscape has historically been a bit messy / fractured. I think the community might eventually settle on https://github.com/typelevel/otel4s
- I see that my database library has switched to that on its main, unreleased branch.
Refinement types
Primer
Refinement types are types with a predicate, such as (pseudocode)
type PosInt = x: Int | x > 0
type ValidPort = x: Int | x >= 0 & x <= 65535
type Email = x: String | x matches ValidEmailRegex
, and so forth.
With refinement types, when using static data (literals), we’re able to accept or reject a value at compile-time. Example (pseudocode):
val x: PosInt = 23 // compiles
val x: PosInt = -1 // compile-time error: -1 is not a PosInt since predicate GreaterThan(0) does not hold
When we’re dealing with dynamic data which might not fulfil the predicate, the possibility of error is reflected in the type:
val x: Either[String, PosInt] = refine[PosInt](21)
In any case, invalid states are made unpresentable, which lets us model domain data types more precisely, and catch data errors earlier, in the outermost layers of our program.
Libraries
Under scala 2.x, the go-to library for refinement types was refined. Unfortunately, while the library is cross-published, compile-time refinement of literals with Scala 3 is not possible.
The solution is to switch to iron, which provides both compile-time and runtime refinement but only exists for Scala 3.
I believe this state of affairs makes the situation
- OK for greenfield projects
- Annoying for codebases migrating from Scala 2 to Scala 3, since switching out such a fundamental library while also switching the language version is a pretty big-bang refactoring
- Very unfortunate for large codebases migrating from Scala 2 to Scala 3, for the same reasons aggravated by scale
- I expect, extremely complicated for libraries which use refinement types and must be cross-published to Scala 2 and Scala 3
There’s good news though! iron
is superior to refined
in that refined types are subtypes of their base types, e.g. Int :| Positive
is a subtype of Int
(whereas refined
uses wrapper types to represent refined types).
This, along with the fact that conversions between the refined type and its base type are inline
d, gives the capability, in many situations, to have zero runtime overhead when using refinement types.
In addition, iron
comes with integrations for your favourite libraries that you’ve come to love and expect, such as circe
, ciris
, skunk
, doobie
, cats
, tapir
, etc.
Typeclass derivation
, which is the compiler being able to infer typeclass instances that are “obvious”, instead of us writing them out by hand.
This is by no means critical, since you can always write your own instances, but it’s handy and makes programs more terse. It’s also good practice as a programmer to automate what you can, because sometimes the most hard to diagnose mistakes are found in boring, seemingly hard to get wrong code.
The situation here has historically been somewhat messy because of the matrix of
- Multiple base mechanisms to implement typeclass derivation (chiefly
shapeless
andmagnolia
) - Multiple flavours / ways of typeclass derivation used by the target libraries that you want to derive instances for (“semi-auto”, “full-auto”, etc)
Out of the mechanisms, magnolia
is preferred over shapeless
in Scala 2, because of the superior (compile-time) performance.
I believe the situation might have gotten even more colourful with Scala 3, as exemplified by this line of documentation in the typelevel/kittens
project:
There are five options for type class derivation on Scala 3.
Rightly so, because Scala 3 provides a basic typeclass derivation mechanism itself, and syntax sugar for typeclass derivation at the call site.
Below follow options for specific libraries. I haven’t yet had a chance to test these out on large-scale codebases, so your mileage (and compile times) may vary.
circe
instances
Use Scala derives
clause, which is going to use Scala 3 metaprogramming facilities under the hood.
package yourproject
import io.circe.Codec
final case class HealthResponse(
message: String
) derives Codec
Sometimes you don’t want to use a derives
clause, because you’re deriving a thing (in this case, Codec
) for a domain datatype, and you don’t want the domain datatype to know about the infrastructure thing.
In that case, forego the syntax sugar and derive the thing outside the datatype:
package yourproject.json
import io.circe.Codec
object DomainCodecs:
given fooCodec: Codec[Foo] = Codec.derived
tapir
instances
Use Scala derives
clause, which is going to use magnolia
facilities under the hood, as far as I can tell.
package yourproject
import sttp.tapir.Schema
final case class Foo(
bar: String
) derives Schema
Same remark about domain datatypes applies as with Codec
s.
cats
instances
Use kittens
, which uses shapeless3
under the hood. kittens
supports Scala 3 derives
clause - see their docs.
magnolify/cats
used to be an option, but at the time of writing it doesn’t support Scala 3.
scalacheck
instances
As already pointed out, there’s no good solution currently. magnolify/scalacheck
used to be an option, but at the time of writing it doesn’t support Scala 3. You might attempt to implement derivation via magnolia
on your own, or else revert to writing instances manually.