Context objects are sometimes encountered in Java where two application layers meet. Possibly the most known examples are ServletRequest
and ServletResponse
objects from Java servlets. Servlets receive these objects from the servlet container and can use them to read information about the request and create a response. In other words, context objects allow the servlet layer to interact with the servlet container layer. They are, for the most part, simple enough to use; however, let's consider a method that accepts a ServletResponse
. What could it do? Well, if we're writing an HTTP servlet, it could either simply check if an appropriate header is set or it could set an error code and send a complete response. This grows more pronounced as the context objects grow larger and for this reason, they are sometimes considered an anti-pattern to avoid. For an example, let's consider a hypothetical enterprise website which has a context object, appropriately named Context
, which allows using all of the functionality present in the codebase. What does a method definition like the following one tell us?
def customisePageTitle(ctx: Context): Unit
The answer is: nothing. This method could either set the page title to a hard-coded string, or it could execute an enterprise targeting rule selected based on a query parameter and a configuration file loaded from disk. Sometimes, we'd like to be a bit more restrictive than that with our method signatures. Of course, one solution is not to use context objects to begin with - hindsight is always 20/20. For those of us who are stuck with context objects in Scala, the question becomes: can we improve the situation without needing an enormous refactoring?
Our own answer are slices. A slice is a trait defining a small part of complete context's functionality, for example:
trait HasMutPageTitle {
def setPageTitle(title: String): Unit
}
Slices, as you might have already guessed, have a naming convention - they all begin with Has
and the ones allowing mutating the context begin with HasMut
. Context
inherits from all slices and defines no methods of its own - in other words, the functionality available from Context
is the sum of all slices. Using slices, methods can be more specific with their signatures - for instance, we could redefine customisePageTitle
as follows:
def customisePageTitle(ctx: HasMutPageTitle): Unit
This makes the method signature clearer - we can now be certain that it will execute no targeting rules. Using Scala's type system, we can also express methods that need multiple slices, for example:
trait HasFilesystem {
def readFile(filename: String): String
// other methods
}
trait HasTargeting {
def executeRule(rule: TargetingRule): Unit
}
def customisePageTitle(ctx: HasFilesystem with HasTargeting): Unit
It's now clear that customisePageTitle
wants (or can) do something more involved than just set the page title directly. As we promised, this requires no modifications to previously existing code - it can remain unaware that there's some additional structure to Context
.
There's one final benefit to slices: they allow working with composable actions. An action here is, essentially, something similar to HasFilesystem => TargetingRule
- a function which needs a slice of the context and returns some result. What's interesting about such functions is that they can be chained. To illustrate, let's first define Action
:
abstract class Action[S >: Context, B] {
def run(slice: S): B
def chain[S2 >: Context, C](f: B => Action[S2, C]): Action[S with S2, C] =
{ s => f(this.run(s)).run(s) }
}
Let's additionally define a helper for creating actions:
def action[S >: Context, B](a: Action[S, B]) = a
This helper by itself seems strange, but it in fact takes advantage of Scala 2.12 SAM support to concisely define actions:
val a = action { ctx: HasFilesystem => ctx.readFile("config.cfg") }
// a : Action[HasFilesystem, String]
Coming back to our previous example - let's say that we have an action Action[HasFilesystem => String]
which loads a name of a targeting rule from some file. We also have a Map[String, TargetingRule]
which can be used to retrieve the rule. We want to end up with an Action[HasFilesystem with HasTargeting, Unit]
which will load the name, retrieve the rule and execute it. All we have to do is:
val ruleMap: Map[String, TargetingRule] = ???
val loadRuleName: Action[HasFilesystem, String] = ???
val loadAndExecuteRule =
loadRuleName.chain { name =>
action { ctx: HasTargeting => ctx.executeRule(ruleMap(name)) }
}
// loadAndExecuteRule : Action[HasFilesystem with HasTargeting, Unit]
Notice that the final type we ended up with is the minimal part of Context
necessary to run the resulting action - no more and no less. This might already seem familiar to monadic types like Future
. In fact, if we named chain
flatMap
instead, we could use for comprehension to define loadAndExecuteRule
:
val loadAndExecuteRule = for {
name <- loadRuleName
_ <- action { ctx: HasTargeting => ctx.executeRule(ruleMap(name)) }
} yield ()
The benefit of Action
is that we can re-use many patterns that we already know from Future
- for example, it's possible to define a function that will turn List[Context => xml.Elem]
into Context => List[xml.Elem]
:
object Action {
def sequence[S >: Context, A](list: List[S => A]): S => List[A] =
{ s => list.map(f => f(s)) }
}
Conclusion
By completely splitting the Context
between small slices with a singular focus, we've allowed defining methods which precisely specify what part of Context
they require, with no refactoring of old code necessary. In addition, we've seen that the resulting structure works nicely when composing actions that can be run when Context
is present, similar to Future
. If you have similar problems to ours, then we very much encourage you to consider structuring your codebase similarly - you might find it as useful as we did.