Propagating context using Scala's using/given
Andreas Hohmann March 17, 2024 #software #scala #contextWe 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.
1 (id: String, title: String, description: String)
2 (title: String, description: String)
3
4 :
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.
1
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.
1 enum LogLevel:
2 case 3 4 5 6 7 8 9 10 11 12 13
These dependencies are a good case for "using" parameters because they are part of the overall setup of the application.
1 private (
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:
1 (using logger: Logger) extends NotebookStore:
2 private val notes = mutable.Map()
3
4 override def save(note: Note): Unit =
5 logger.logInfo(s"saving note ${}")
6 notes(note.id) = note
7 logger.logInfo(s"saved note ${}")
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.
1 (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
6 extends IdGenerator:
7 override def generateId(): String = java.util.UUID.randomUUID().toString
8
9 def main(): Unit = 10 11 12 13 14 15 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):
1 (using logger: Logger) extends NotebookStore:
2
3 private val notes = mutable.Map()
4
5 override def save(note: Note): Unit =
6 logInfo(s"saving note ${}")
7 notes(note.id) = note
8 logInfo(s"saved note ${}")
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.
(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.
1 :
2 def save(note: Note)(using context: Context): Unit
3 def get(id: String): Option[Note]
4
5 :
6 def saveNote(newNote: NewNote)(using context: Context): Note
7
8 private
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 ${}")
16 note
17
18 (using logger: Logger) extends NotebookStore:
19
20 private val notes = mutable.Map()
21
22 override def save(note: Note)(using context: Context): Unit =
23 logInfo(s"saving note ${} for user ${}")
24 notes(note.id) = note
25 logInfo(s"saved note ${} for user ${}")
26
27 override def get(id: String): Option[Note] = notes.get(id)
28
29 def main(): Unit = 30 31 32 33 34 35 36 37 38 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.