Scala 3 modules: How to build modular systems

This is an excerpt from the Scala Cookbook, 2nd Edition. This is Recipe 24.7, Building Modular Systems with Scala 3.

Problem

You’re familiar with Martin Odersky’s statement that Scala developers should use “functions for the logic, and objects for the modularity,” so you want to know how to build modules in Scala.

Solution

To understand this solution, you have to understand the concept of a module. The book, Programming in Scala — co-written by Martin Odersky, creator of the Scala language — describes a module as “a ‘smaller program piece’ with a well defined interface and a hidden implementation.” More importantly, it adds this discussion:

Any technique that aims to facilitate this kind of modularity needs to provide a few essentials.

First, there should be a module construct that provides a good separation of interface and implementation.

Second, there should be a way to replace one module with another that has the same interface without changing or recompiling the modules that depend on the replaced one.

Lastly, there should be a way to wire modules together. This wiring task can by thought of as configuring the system.

In regards to these three points, Scala provides these solutions:

  • Inheritance and mixins with traits, classes, and objects provide a good separation of interface and implementation

  • Inheritance also provides a mechanism for one module to be replaced by another

  • Creating objects (“reifying” them) from traits provides a way to wire modules together

With a modular approach you write code like this:

trait Database { ... }
object MockDatabase extends Database { ... }
object TestDatabase extends Database { ... }
object ProductionDatabase extends Database { ... }

With this approach you define the desired method signatures — the interface — in the base Database trait, and potentially implement some behavior there as well. Then you create the three objects for your Dev, Test, and Production environments. The actual implementation may be a little more complicated than this, but that’s the basic idea.

The recipe for programming with modules in Scala goes like this:

  1. Think about your problem, and create one or more base traits to model the interface for the problem

  2. Implement your interfaces with pure functions in more traits that extend the base trait

  3. Combine (compose) the traits together as needed to create other traits

  4. When necessary and desirable, create an object from those traits

A Scala 3 example

Here’s an example of this technique. Imagine that you want to define the behaviors for a dog, let’s say an Irish Setter. One way to do this is to jump right in and create an IrishSetter class:

class IrishSetter { ... }

This is generally a bad idea. A better idea is to think about the interfaces for different types of dog behaviors, and then build a specific implementation of an Irish Setter when you’re ready.

For example, an initial thought is that a dog is an animal:

trait Animal

More specifically, a dog is an animal with a tail, and that tail has a color:

abstract class AnimalWithTail(tailColor: Color) extends Animal

Next, you might think, “Since a dog has a tail, what kind of behaviors can a tail have?” With that thought, you’ll sketch a trait like this:

trait DogTailServices:
    def wagTail = ???
    def lowerTail = ???
    def raiseTail = ???

Next, because you know that you only want this trait to be mixed into classes that extend AnimalWithTail, you’ll add a self-type to the trait:

trait DogTailServices:
    // implementers must be a sub-type of AnimalWithTail
    this: AnimalWithTail =>

    def wagTail = ???
    def lowerTail = ???
    def raiseTail = ???

As shown in the traits lessons, this peculiar looking line declares a Scala self-type:

this: AnimalWithTail =>

This self-type means, “This trait can only be mixed into other traits, classes, and objects that extend AnimalWithTail.” Trying to mix it into other types results in a compiler error.

To keep this example simple, I’ll go ahead and implement the functions (“services”) in the DogTailServices trait like this:

trait DogTailServices:
    this: AnimalWithTail =>
    def wagTail() = println("wagging tail")
    def lowerTail() = println("lowering tail")
    def raiseTail() = println("raising tail")

Next, as I think more about a dog, I know that it has a mouth, so I sketch another trait like this:

trait DogMouthServices:
    this: AnimalWithTail =>
    def bark() = println("bark!")
    def lick() = println("licking")

I could keep going on like this, but I hope you see the idea: You think about the services — the behaviors or functions — that are associated with a domain object (like a dog), and then you sketch those services as pure functions in logically organized traits.

Tip: Don’t Get Bogged Down

When it comes to designing traits, just start with your best ideas, then re-organize them as your thinking becomes more clear. As an example, the Scala collections classes have been redesigned several times as the designers understood the problems better.

Since I’m not going to go further and define more dog-related behaviors, I’ll stop at this point, and now I’ll create a module as an implementation of an Irish Setter with the services I’ve defined so far:

object IrishSetter extends
    AnimalWithTail(Color.red),
    DogTailServices,
    DogMouthServices

If you start the REPL and import the necessary Color class:

scala> import java.awt.Color
import java.awt.Color

and then import all of those traits into the REPL (not shown here), you’ll see that you can call the functions/services on your IrishSetter:

scala> IrishSetter.wagTail()
wagging tail

scala> IrishSetter.bark()
bark!

While this is a relatively simple example, it shows the general process of “programming with modules” in Scala.

About the name “service”

The name service comes from the fact that these functions provide a series of public “services” that are essentially available to other programmers. Although you’re welcome to use any name, I find that this name makes sense when you imagine that these functions are implemented as a series of web service calls. For instance, when you use Twitter’s REST API to write a Twitter client, the functions they make available to you in that API are considered to be a series of web services.

Discussion

The reasons for adopting a modular programming approach are described in Programming in Scala:

As a program grows in size, it becomes increasingly important to organize it in a modular way.

First, being able to compile different modules that make up the system separately helps different teams work independently.

In addition, being able to unplug one implementation of a module and plug in another is useful, because it allows different configurations of a system to be used in different contexts, such as unit testing on a developer’s desktop, integration testing, staging, and deployment.

In regards to the first point, in functional programming it’s nice to be able to say, “Hey, Team A, how about if you work on the Order functions, and Team B will work on the Pizza functions?”

In regards to the second point, a good example is that you might use a mock database in your Dev environment, and then use real databases in the Test and Production environments. In this case you’ll create traits like these:

trait Database { ... }
object MockDatabase extends Database { ... }
object TestDatabase extends Database { ... }
object ProductionDatabase extends Database { ... }

A detailed variation of this example is shown in an article at artima.com titled, Modular Programming Using Objects.