Using Scala traits as modules, or the “Thin Cake” Pattern
I would like to describe a pure-Scala approach to modularity that we are successfully using in a couple of our Scala projects.
But let’s start with how we do Dependency Injection (see also my other blogs). Each class can have dependencies in the form of constructor parameters, e.g.:
class WheatField class Mill(wheatField: wheatField) class CowPasture class DiaryFarm(cowPasture: CowPasture) class Bakery(mill: Mill, dairyFarm: DairyFarm)
At the “end of the world”, there is a main class which runs the application and where the whole object graph is created:
object BakeMeCake extends App { // creating the object graph lazy val wheatField = new WheatField() lazy val mill = new Mill(wheatField) lazy val cowPasture = new CowPasture() lazy val diaryFarm = new DiaryFarm(cowPasture) lazy val bakery = new Bakery(mill, dairyFarm) // using the object graph val cake = bakery.bakeCake() me.eat(cake) }
The wiring can be done manually, or e.g. using MacWire.
Note that we can do scoping using Scala constructs: a lazy val
corresponds to a singleton object (in the constructed object graph), a def
to a dependent-scoped object (a new instance will be created for each usage).
Thin Cake pattern
What if the object graph, and at the same time the main class, becomes large? The answer is simple: we have to break it into pieces, which will be the “modules”. Each module is a Scala trait
, and contains some part of the object graph.
For example:
trait CropModule { lazy val wheatField = new WheatField() lazy val mill = new Mill(wheatField) } trait LivestockModule { lazy val cowPasture = new CowPasture() lazy val diaryFarm = new DiaryFarm(cowPasture) }
The main object then becomes a composition of traits. This is exactly what also happens in the Cake Pattern. However here we are using only one element of it, hence the “Think Cake” Pattern name.
object BakeMeCake extends CropModule with LivestockModule { lazy val bakery = new Bakery(mill, dairyFarm) val cake = bakery.bakeCake() me.eat(cake) }
If you have ever used Google Guice, you may see a similarity: trait-modules directly correspond to Guice modules. However, here we gain the additional type-safety and compile-time checking that dependency requirements for all classes are met.
Of course, the module trait can contain more than just new object instantiations, however you have to be cautious not to put too much logic in there – at some point you probably need to extract a class. Typical code that also goes into modules is e.g. new actor creation code and setting up caches.
Dependencies
What if our trait modules have inter-module dependencies? There are two ways we can deal with that problem.
The first is abstract members. If there’s an instance of a class that is needed in our module, we can simply define it as an abstract member of the trait-module. This abstract member has to be then implemented in some other module with which our module gets composed in the end. Using a consistent naming convention helps here. The fact that all abstract dependencies are defined at some point is checked by the compiler.
The second way is composition via inheritance. If we e.g. want to create a bigger module out of three smaller modules, we can simply extend the other module-traits, and due to the way inheritance works we can use all of the objects defined there.
Putting the two methods together we get for example:
// composition via inheritance: bakery depends on crop and livestock modules trait BakeryModule extends CropModule with LivestockModule { lazy val bakery = new Bakery(mill, dairyFarm) } // abstract member: we need a bakery trait CafeModule { lazy val espressoMachine = new EspressoMachine() lazy val cafe = new Cafe(bakery, espressoMachine) def bakery: Bakery } // the abstract bakery member is implemented in another module object CafeApp extends CafeModule with BakeryModule { cafe.orderCoffeeAndCroissant() }
Multiple implementations
Taking this idea a bit further, in some situations we might have trait-module-interfaces and several trait-module-implementions. The interface would contain only abstract members, and the implementations would wire the appropriate classes. If other modules depend only on the trait-module-interface, when we do the final composition we can use any implementation.
This isn’t perfect, however. The implementation must be known statically, when writing the code – we cannot dynamically decide which implementations we want to use. If we want to dynamically choose an implementation for only one trait-interface, that’s not a problem – we can use a simple “if”. But every additional combination causes an exponential increase in the cases we have to cover. For example:
trait MillModule { def mill: Mill } trait CornMillModule extends MillModule { lazy val cornField = new CornField() lazy val mill = new CornMill(cornField) } trait WheatMillModule extends MillModule { lazy val wheatField = new WheatField() lazy val mill = new WheatMill(wheatField) } val modules = if (config.cornPreferred) { new BakeryModule with CornMillModule } else { new BakeryModule with WheatMillModule }
Can it be any better?
Sure! There’s always something to improve :). One of the problems was already mentioned – you cannot choose which trait-module to use dynamically (run-time configuration).
Another area that could get improved is the relation between trait-modules and packages. A good approach is to have a single trait-module per package (or per package tree). That way you logically group code that implements some functionality in a single package, and specify how the classes that form the implementations should be used in the trait-module. But why then do you have to define both the package and trait-module? Maybe they can be merged together somehow? Increasing the role of packages is also an idea I’ve been exploring in the Veripacks project.
It may also be good to restrict the visibility of some of the defined objects. Following the “one public class per package” rule, here we might have “one public object per trait-module”. However, if we are creating bigger trait-modules out of smaller ones, the bigger module has no way to restrict the visibility of the objects in the module it composes of. In fact, the smaller modules would have to know the maximum scope of their visibility and use an appropriate private[package name] modifier (supposing the bigger module is in a parent package).
Summing up
Overall, we found this solution to be a simple, clear way to structure our code and create the object graph. It uses only native Scala constructs, does not depend on any frameworks or libraries, and provides compile-time checking that everything is defined properly.
Bon Appetit!