Propagating context using Scala's using/given

Andreas Hohmann March 17, 2024 #software #scala #context

We have all seen and used the infamous "context" object. Some information, whether it's the current user's authentication and authorization information or the current transaction, has to be passed through all the nice layers and slices of our software system. It starts small and, if not carefully controlled, becomes a large incoherent grab-bag of data over time. We can try to hide it in a thread-local variable, which at least avoids the pollution of the APIs in our application with the "context" parameter, or use some dependency injection mechanism (often also relying on thread-local variables), but this only moves the explicit dependency to an implicit one that is harder to follow and test.

John Ousterhout mentions the "context" object in his book A Philosophy of Software Design (see my previous post) as one of the examples of a necessary "pass-through variable" for which he hasn't found a better solution.

There have been attempts, however. Scala's implicit arguments and in particular Scala 3's new using/given syntax as well as Kotlin's context receivers offer some relief (if not a complete solution), and Olin even has an implicit context argument that is passed to every function.

This post takes a closer look at Scala 3's mechanism. Scala's multiple argument lists ("currying") allow us to split the arguments of a function or constructor into multiple groups. A constructor of some service class, for example, may take some configuration arguments as well as references to other services. The arguments of a parameter list marked with using are given either explicitly or as part of the context of the function call.

Let's take a (primitive) notebook with a single saveNote method as an example.

1case class Note(id: String, title: String, description: String)
2case class NewNote(title: String, description: String)
3
4trait Notebook:
5 def saveNote(newNote: NewNote): Note

Its implementation needs to generate an ID, create the new note, store it, and return it to the caller. We may also want to log this activity.

1class NotebookImpl
2 ...
3 override def saveNote(newNote: NewNote): Note =
4 val id = idGenerator.generateId()
5 val note = Note(id, newNote.title, newNote.description)
6 store.save(note)
7 logger.logInfo(s"saved note $id")
8 note

To do that, the implementation could depend on three services for logging, ID generation, and storage.

1enum LogLevel:
2 case Debug, Info, Warning, Error
3
4trait Logger:
5 def log(logLevel: LogLevel, message: => String): Unit
6 def logInfo(message: => String): Unit = log(Info, message)
7
8trait IdGenerator:
9 def generateId(): String
10
11trait NotebookStore:
12 def save(note: Note): Unit
13 def get(id: Long): Option[Note]

These dependencies are a good case for "using" parameters because they are part of the overall setup of the application.

1private class NotebookImpl(
2 using logger: Logger, store: NotebookStore, idGenerator: IdGenerator
3) extends Notebook:
4 override def saveNote(newNote: NewNote): Note =
5 val id = idGenerator.generateId()
6 val note = Note(id, newNote.title, newNote.description)
7 store.save(note)
8 logger.logInfo(s"saved note $id")
9 note

The implementation of the storage layer may also use the logger:

1class TestNotebookStore(using logger: Logger) extends NotebookStore:
2 private val notes = mutable.Map[String, Note]()
3
4 override def save(note: Note): Unit =
5 logger.logInfo(s"saving note ${note.id}")
6 notes(note.id) = note
7 logger.logInfo(s"saved note ${note.id}")
8
9 override def get(id: String): Option[Note] = notes.get(id)

To plug everything together, let's add simple implementations of the logger and the ID generator and "give" these implementations implicitly.

1class ConsoleLogger(shownLevel: LogLevel = Info) extends Logger:
2 override def log(logLevel: LogLevel, message: => String): Unit =
3 if logLevel.ordinal >= shownLevel.ordinal then
4 println(s"$logLevel: $message")
5
6class UuidIdGenerator extends IdGenerator:
7 override def generateId(): String = java.util.UUID.randomUUID().toString
8
9@main def main(): Unit = {
10 given Logger = ConsoleLogger()
11 given NotebookStore = MapNotebookStore()
12 given IdGenerator = UuidIdGenerator()
13
14 val notebook: Notebook = NotebookImpl()
15 notebook.saveNote(NewNote("Givens in Scala", "context propagation with using/given"))
16}

While it doesn't make a big difference for this tiny example, it is easy to imagine how this technique scales to larger application. Another Scala feature can make this code even cleaner: object imports. The log methods are easily recognized without the logger reference, and importing these methods in the class is an astonishingly simple way to avoid this noise and shorten the log lines (which often tend to be long):

1class MapNotebookStore(using logger: Logger) extends NotebookStore:
2 import logger.*
3 private val notes = mutable.Map[String, Note]()
4
5 override def save(note: Note): Unit =
6 logInfo(s"saving note ${note.id}")
7 notes(note.id) = note
8 logInfo(s"saved note ${note.id}")
9
10 override def get(id: String): Option[Note] = notes.get(id)

So far we have used the using/given mechanism only for dependency injection. Let's extend the example to take advantage of the implicit pass-through arguments. Let's say we want to log the user ID when saving the note in the storage layer. The user ID could be kept in a context object.

case class Context(userId: String)

To pass the context through the layers we can add it in a using parameter list and rely on the compiler to implicitly set the argument.

1trait NotebookStore:
2 def save(note: Note)(using context: Context): Unit
3 def get(id: String): Option[Note]
4
5trait Notebook:
6 def saveNote(newNote: NewNote)(using context: Context): Note
7
8private class NotebookImpl
9 (using logger: Logger, store: NotebookStore, idGenerator: IdGenerator) extends Notebook:
10
11 override def saveNote(newNote: NewNote)(using context: Context): Note =
12 val id = idGenerator.generateId()
13 val note = Note(id, newNote.title, newNote.description)
14 store.save(note)
15 logger.logInfo(s"saved note ${note.id}")
16 note
17
18class MapNotebookStore(using logger: Logger) extends NotebookStore:
19 import logger.*
20 private val notes = mutable.Map[String, Note]()
21
22 override def save(note: Note)(using context: Context): Unit =
23 logInfo(s"saving note ${note.id} for user ${context.userId}")
24 notes(note.id) = note
25 logInfo(s"saved note ${note.id} for user ${context.userId}")
26
27 override def get(id: String): Option[Note] = notes.get(id)
28
29@main def main(): Unit = {
30 given Logger = ConsoleLogger()
31 given NotebookStore = MapNotebookStore()
32 given IdGenerator = UuidIdGenerator()
33
34 val notebook: Notebook = NotebookImpl()
35
36 val newNote = NewNote("Givens in Scala", "context propagation with using/given")
37 given Context = Context(userId = "some-user") // e.g., coming from web framework
38 notebook.saveNote(newNote)
39}

We still have to declare the context in the method signatures, but never have to pass it explicitly. While requiring a bit more work, I prefer the change of the method signatures over less obvious techniques such as thread-local variables or other injection magic. All in all, Scala 3's using/given strikes a nice balance of clarity and conciseness.