Context passing is the thing that all programmers face regardless of a programming language they use. In this article I’d like to discuss the ways how this can be solved in Scala using Cats Effect, ZIO, cats-mtl and the tagless final encoding.
Context and its way through
Before going further with coding we should answer a few questions - What is a context? and Why do we need to pass it?.
What is a context?
In this article I would use context
in the meaning of preloaded structured immutable information about something we need across a stack of calls made in our business logic(function).
Context should have the next properties:
- Info should be loaded to memory - in case if a part of the context is stored in external memory, this info should be loaded from it in advance BEFORE executing the logic.
Opposite would be requesting the info from the external memory only when it is needed in the business logic. - Info should be extracted/parsed/validated - info should be parsed into a domain specific data structure and validated.
Opposite would be having this context info represented as a string or JSON or any other low-level data representation. - Info should be immutable during execution of an operation - data structure and info in it is immutable after it is prepared. It does not represent the latest changes that could be made in the external memory during execution of the operation.
- The operation itself is considered as short-lived
Why do we need to pass it?
Here are some practical examples of a context:
- Information about a user account that is used for a http request - like its id for tracking user actions in the system.
- Session information of the account like access roles assigned to it - for checking if certain actions are allowed across the business logic.
- Information about the http request itself - a request id, a request’s timestamp and other info useful for logging and tracing.
Explicit context passing
In functional Scala we have quite a lot of different tools to be used. But let’s start with the simplest one we have - an explicit value. We pass a context as an argument of a function or a constructor parameter in this case.
object Example {
// Passing `context` as an explicit argument
def passContext1(arg1: Arg1, arg2: Arg2, context: Context): Result = ???
// Passing `context` as an implicit argument
def passContext2(arg1: Arg1, arg2: Arg2)(using context: Context): Result = ???
}
// Passing `context` as an explicit constructor parameter
class PassContext3(arg1: Arg1, arg2: Arg2, context: Context) {
def doSomething(): Result
}
// Passing `context` as an implicit constructor parameter
class PassContext4(arg1: Arg1, arg2: Arg2)(using Context) {
def doSomething(): Result
}
This is the simplest way of passing a context in Scala. The disadvantage here is the fact that the context value must be passed through all layers of a call stack even if it is used on in a few of them. All methods have to have Context
parameter defined explicitly in their signatures whether as an explicit or an implicit parameter.
ThreadLocal
Another way is the old way of Java - ThreadLocal
(or InheritableThreadLocal
).
object ContextHolder {
private val context = new ThreadLocal[Context]()
def setContext(in: Context): Unit = context.set(in)
def getContext(): Context = context.get()
def dropContext(): Unit = context.remove()
}
class Controller(service: Service) {
// Here is happy-path logic only
def doSomething(args: Args): Result = {
val context = ??? // get context from somewhere
ContextHolder.setContext(context)
service.doSomethingElse()
ContextHolder.dropContext()
}
}
class Service {
def doSomethingElse(): Result = {
val context = ContextHolder.getContext()
//... context dependent logic is here
???
}
}
I can’t recommend to use it in Scala code nowadays as it is way too unsafe in terms of null values and memory leaks. Also it is necessary to be mindful about releasing/replacing the old context value with the new one. Another thing is understanding how the logic is mapped on threads. For example, in cats-effect it might be quite difficult to understand what particular thread is used for executing one or another line of logic. The reason is that different parts of a program can be run on different threads in an execution context.
Cats’ IOLocal
IOLocal
is an alternative for ThreadLocal
made specifically for cats-effect. The idea behind it is the same as for ThreadLocal
but it can work properly around cat’s Fiber
-s.
For more details check this example.
ZIO’s FiberRef
Zio also has its own implementation of a fiber local values - FiberRef
.
More information about it and how to use it for passing a context around can be found here.
Context passing in Tagless Final
But there is, I think, the better way of doing what we are doing with our context - use type classes and the Tagless Final approach 😎
Services
First of all, let’s define our types that we are going to use in this example.
Context
- is a representation of the context we are going to pass across our calls’ stack.Input
- is a type for input params. For simplicity let’s use this one type for all inputs.Output
- is the same but for outputs of our functions.
Then we need to bring a type class from cats-mtl - Ask[F[_], Ctx]
. It makes possible of getting a value of Ctx
out from F[_]
.
type Context
type Input
type Output
trait Layer1[F[_]]:
def operation1(in: Input): F[Output]
trait Layer2[F[_]]:
def operation2(in: Input): F[Output]
trait Layer3[F[_]]:
def operation3(in: Input): F[Output]
trait Layer4[F[_]]:
def operation4(in: Input): F[Output]
Now we need to bring a type class from cats-mtl - Ask[F[_], Ctx]
. It makes possible of getting a value of Ctx
out from F[_]
. To improve readability we can define such a type alias that we can use later as a single argument type class.
import cats.mtl.Ask
final type AskCtx[F[_]] = Ask[F, Context]
Next step is to implement these layers. Our goal here is to pass the context value between all layers but use it only in Layer2
and Layer4
.
Impl1
does nothing but calls the next layer. It does not use the context.
final class Impl1[F[_]](layer2: Layer2[F]) extends Layer1[F]:
override def operation1(in: Input): F[Output] = layer2.operation2(in)
Impl2
extracts a context value and verifies it.
final class Impl2[F[_]](
verify: Context => F[Boolean],
layer3: Layer3[F]
)(using A: AskCtx[F], MT: MonadError[F, Throwable])
extends Layer2[F]:
override def operation2(in: Input): F[Output] = for {
context <- A.ask
result <- verify(context)
output <-
if (result)
layer3.operation3(in)
else
// Better way of working with errors
// is going to be shown in the `error-handling` part
MT.raiseError(new RuntimeException(s"Could not verify context: $context"))
} yield output
Impl3
does nothing as well.
final class Impl3[F[_]](layer4: Layer4[F]) extends Layer3[F]:
override def operation3(in: Input): F[Output] = layer4.operation4(in)
And Impl4
does “the real work”.
final class Impl4[F[_]: Monad](
doRealWork: Input => F[Output],
println: String => F[Unit]
)(using
A: AskCtx[F]
) extends Layer4[F]:
override def operation4(in: Input): F[Output] = for
context <- A.ask
output <- doRealWork(in)
_ <- println(s"{Context: $context} -> (Input: $in) -> (Output: $output)")
yield output
As you can see the layers that don’t require the context for their logic has no knowledge about it - it is not passed there neither as a method argument nor a constructor parameter. Also this code can work both with Cats and ZIO.
Using Cats-Effect
IO[..]
in cats-effect does not have a channel for passing a context value. So that we use Kleisli[IO, Context, ..]
to add Context
to IO
.
object CatsExample extends IOApp.Simple with Example:
final case class User(name: String)
override type Context = User
override type Input = String
override type Output = Int
// Add Context to IO using Kleisli
private type CtxIO[A] = Kleisli[IO, Context, A]
private def doRealWork(in: Input): CtxIO[Output] = in.length.pure
private def println(in: String): CtxIO[Unit] = Console[CtxIO].println(in)
private def verify(ctx: Context): CtxIO[Boolean] = ctx.name.nonEmpty.pure
override def run: IO[Unit] =
// type definitions are for clarity only
val layer4: Layer4[CtxIO] = Impl4(doRealWork, println)
val layer3: Layer3[CtxIO] = Impl3(layer4)
val layer2: Layer2[CtxIO] = Impl2(verify, layer3)
val layer1: Layer1[CtxIO] = Impl1(layer2)
val validCtx: Context = User("valid_name")
val invalidCtx: Context = User("")
val input: Input = "non_empty_string"
for
_ <- Console[IO].println("Let's go!")
ctxProgram = layer1.operation1(input)
result <- ctxProgram.run(validCtx)
// result <- ctxProgram.run(invalidCtx)
_ <- Console[IO].println(s"result: $result")
yield ()
Using ZIO
ZIO[..]
already have a channel for a context, so there is no need in using Kleisli[..]
. Just construct a program and run it by passing a context value wrapped with ZLayer.succeed(..)
.
object ZIOExample extends ZIOAppDefault with Example:
final case class User(name: String)
override type Context = User
override type Input = String
override type Output = Int
private type CtxIO[A] = ZIO[Context, Throwable, A]
private def doRealWork(in: Input): CtxIO[Output] =
Exit.succeed(in.length)
private def println(in: String): CtxIO[Unit] =
Console.printLine(in)
private def verify(ctx: Context): CtxIO[Boolean] =
Exit.succeed(ctx.name.nonEmpty)
override def run: ZIO[Any & ZIOAppArgs & Scope, Any, Any] =
// type definitions are here for clarity only
val layer4: Layer4[CtxIO] = Impl4[CtxIO](doRealWork, println)
val layer3: Layer3[CtxIO] = Impl3(layer4)
val layer2: Layer2[CtxIO] = Impl2(verify, layer3)
val layer1: Layer1[CtxIO] = Impl1(layer2)
val validCtx: Context = User("valid_name")
val invalidCtx: Context = User("")
val input: Input = "non_empty_string"
for
_ <- Console.print("Let's go!")
// Construct a program that requires a context value
ctxProgram = layer1.operation1(input)
// Pass the context value to run the program
result <- ctxProgram.provide(ZLayer.succeed(validCtx))
// result <- ctxProgram.provide(ZLayer.succeed(invalidCtx))
_ <- Console.printLine(s"result: $result")
yield ()
Links
- Source Code
- ThreadLocal JavaDoc
- InheritableThreadLocal JavaDoc
- Example for Cats’ IOLocal
- Example for ZIO’s FiberRef