Scala 3 — Book

FP Modeling

Language

This chapter provides an introduction to domain modeling using functional programming (FP) in Scala 3. When modeling the world around us with FP, you typically use these Scala constructs:

  • Enumerations
  • Case classes
  • Traits

If you’re not familiar with algebraic data types (ADTs) and their generalized version (GADTs), you may want to read the Algebraic Data Types section before reading this section.

Introduction

In FP, the data and the operations on that data are two separate things; you aren’t forced to encapsulate them together like you do with OOP.

The concept is similar to numerical algebra. When you think about whole numbers whose values are greater than or equal to zero, you have a set of possible values that looks like this:

0, 1, 2 ... Int.MaxValue

Ignoring the division of whole numbers, the possible operations on those values are:

+, -, *

In FP, business domains are modeled in a similar way:

  • You describe your set of values (your data)
  • You describe operations that work on those values (your functions)

As we will see, reasoning about programs in this style is quite different from the object-oriented programming. Data in FP simply is: Separating functionality from your data lets you inspect your data without having to worry about behavior.

In this chapter we’ll model the data and operations for a “pizza” in a pizza store. You’ll see how to implement the “data” portion of the Scala/FP model, and then you’ll see several different ways you can organize the operations on that data.

Modeling the Data

In Scala, describing the data model of a programming problem is simple:

  • If you want to model data with different alternatives, use the enum construct, (or case object in Scala 2).
  • If you only want to group things (or need more fine-grained control) use case classes

Describing Alternatives

Data that simply consists of different alternatives, like crust size, crust type, and toppings, is precisely modelled in Scala by an enumeration.

In Scala 2 enumerations are expressed with a combination of a sealed class and several case object that extend the class:

sealed abstract class CrustSize
object CrustSize {
  case object Small extends CrustSize
  case object Medium extends CrustSize
  case object Large extends CrustSize
}

sealed abstract class CrustType
object CrustType {
  case object Thin extends CrustType
  case object Thick extends CrustType
  case object Regular extends CrustType
}

sealed abstract class Topping
object Topping {
  case object Cheese extends Topping
  case object Pepperoni extends Topping
  case object BlackOlives extends Topping
  case object GreenOlives extends Topping
  case object Onions extends Topping
}

In Scala 3 enumerations are concisely expressed with the enum construct:

enum CrustSize:
  case Small, Medium, Large

enum CrustType:
  case Thin, Thick, Regular

enum Topping:
  case Cheese, Pepperoni, BlackOlives, GreenOlives, Onions

Data types that describe different alternatives (like CrustSize) are also sometimes referred to as sum types.

Describing Compound Data

A pizza can be thought of as a compound container of the different attributes above. We can use a case class to describe that a Pizza consists of a crustSize, crustType, and potentially multiple toppings:

import CrustSize._
import CrustType._
import Topping._

case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
)
import CrustSize.*
import CrustType.*
import Topping.*

case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
)

Data Types that aggregate multiple components (like Pizza) are also sometimes referred to as product types.

And that’s it. That’s the data model for an FP-style pizza system. This solution is very concise because it doesn’t require the operations on a pizza to be combined with the data model. The data model is easy to read, like declaring the design for a relational database. It is also very easy to create values of our data model and inspect them:

val myFavPizza = Pizza(Small, Regular, Seq(Cheese, Pepperoni))
println(myFavPizza.crustType) // prints Regular

More of the data model

We might go on in the same way to model the entire pizza-ordering system. Here are a few other case classes that are used to model such a system:

case class Address(
  street1: String,
  street2: Option[String],
  city: String,
  state: String,
  zipCode: String
)

case class Customer(
  name: String,
  phone: String,
  address: Address
)

case class Order(
  pizzas: Seq[Pizza],
  customer: Customer
)

“Skinny domain objects”

In his book, Functional and Reactive Domain Modeling, Debasish Ghosh states that where OOP practitioners describe their classes as “rich domain models” that encapsulate data and behaviors, FP data models can be thought of as “skinny domain objects.” This is because—as this lesson shows—the data models are defined as case classes with attributes, but no behaviors, resulting in short and concise data structures.

Modeling the Operations

This leads to an interesting question: Because FP separates the data from the operations on that data, how do you implement those operations in Scala?

The answer is actually quite simple: you simply write functions (or methods) that operate on values of the data we just modeled. For instance, we can define a function that computes the price of a pizza.

def pizzaPrice(p: Pizza): Double = p match {
  case Pizza(crustSize, crustType, toppings) => {
    val base  = 6.00
    val crust = crustPrice(crustSize, crustType)
    val tops  = toppings.map(toppingPrice).sum
    base + crust + tops
  }
}
def pizzaPrice(p: Pizza): Double = p match
  case Pizza(crustSize, crustType, toppings) =>
    val base  = 6.00
    val crust = crustPrice(crustSize, crustType)
    val tops  = toppings.map(toppingPrice).sum
    base + crust + tops

You can notice how the implementation of the function simply follows the shape of the data: since Pizza is a case class, we use pattern matching to extract the components and call helper functions to compute the individual prices.

def toppingPrice(t: Topping): Double = t match {
  case Cheese | Onions => 0.5
  case Pepperoni | BlackOlives | GreenOlives => 0.75
}
def toppingPrice(t: Topping): Double = t match
  case Cheese | Onions => 0.5
  case Pepperoni | BlackOlives | GreenOlives => 0.75

Similarly, since Topping is an enumeration, we use pattern matching to distinguish between the different variants. Cheese and onions are priced at 50ct while the rest is priced at 75ct each.

def crustPrice(s: CrustSize, t: CrustType): Double =
  (s, t) match {
    // if the crust size is small or medium,
    // the type is not important
    case (Small | Medium, _) => 0.25
    case (Large, Thin) => 0.50
    case (Large, Regular) => 0.75
    case (Large, Thick) => 1.00
  }
def crustPrice(s: CrustSize, t: CrustType): Double =
  (s, t) match
    // if the crust size is small or medium,
    // the type is not important
    case (Small | Medium, _) => 0.25
    case (Large, Thin) => 0.50
    case (Large, Regular) => 0.75
    case (Large, Thick) => 1.00

To compute the price of the crust we simultaneously pattern match on both the size and the type of the crust.

An important point about all functions shown above is that they are pure functions: they do not mutate any data or have other side-effects (like throwing exceptions or writing to a file). All they do is simply receive values and compute the result.

How to Organize Functionality

When implementing the pizzaPrice function above, we did not say where we would define it. Scala gives you many great tools to organize your logic in different namespaces and modules.

There are several different ways to implement and organize behaviors:

  • Define your functions in companion objects
  • Use a modular programming style
  • Use a “functional objects” approach
  • Define the functionality in extension methods

These different solutions are shown in the remainder of this section.

Companion Object

A first approach is to define the behavior—the functions—in a companion object.

As discussed in the Domain Modeling Tools section, a companion object is an object that has the same name as a class, and is declared in the same file as the class.

With this approach, in addition to the enumeration or case class you also define an equally named companion object that contains the behavior.

case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
)

// the companion object of case class Pizza
object Pizza {
  // the implementation of `pizzaPrice` from above
  def price(p: Pizza): Double = ...
}

sealed abstract class Topping

// the companion object of enumeration Topping
object Topping {
  case object Cheese extends Topping
  case object Pepperoni extends Topping
  case object BlackOlives extends Topping
  case object GreenOlives extends Topping
  case object Onions extends Topping

  // the implementation of `toppingPrice` above
  def price(t: Topping): Double = ...
}
case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
)

// the companion object of case class Pizza
object Pizza:
  // the implementation of `pizzaPrice` from above
  def price(p: Pizza): Double = ...

enum Topping:
  case Cheese, Pepperoni, BlackOlives, GreenOlives, Onions

// the companion object of enumeration Topping
object Topping:
  // the implementation of `toppingPrice` above
  def price(t: Topping): Double = ...

With this approach you can create a Pizza and compute its price like this:

val pizza1 = Pizza(Small, Thin, Seq(Cheese, Onions))
Pizza.price(pizza1)

Grouping functionality this way has a few advantages:

  • It associates functionality with data and makes it easier to find for programmers (and the compiler).
  • It creates a namespace and for instance lets us use price as a method name without having to rely on overloading.
  • The implementation of Topping.price can access enumeration values like Cheese without having to import them.

However, there are also a few tradeoffs that should be considered:

  • It tightly couples the functionality to your data model. In particular, the companion object needs to be defined in the same file as your case class.
  • It might be unclear where to define functions like crustPrice that could equally well be placed in a companion object of CrustSize or CrustType.

Modules

A second way to organize behavior is to use a “modular” approach. The book, Programming in Scala, defines a module as, “a ‘smaller program piece’ with a well-defined interface and a hidden implementation.” Let’s look at what this means.

Creating a PizzaService interface

The first thing to think about are the Pizzas “behaviors”. When doing this, you sketch a PizzaServiceInterface trait like this:

trait PizzaServiceInterface {

  def price(p: Pizza): Double

  def addTopping(p: Pizza, t: Topping): Pizza
  def removeAllToppings(p: Pizza): Pizza

  def updateCrustSize(p: Pizza, cs: CrustSize): Pizza
  def updateCrustType(p: Pizza, ct: CrustType): Pizza
}
trait PizzaServiceInterface:

  def price(p: Pizza): Double

  def addTopping(p: Pizza, t: Topping): Pizza
  def removeAllToppings(p: Pizza): Pizza

  def updateCrustSize(p: Pizza, cs: CrustSize): Pizza
  def updateCrustType(p: Pizza, ct: CrustType): Pizza

As shown, each method takes a Pizza as an input parameter—along with other parameters—and then returns a Pizza instance as a result

When you write a pure interface like this, you can think of it as a contract that states, “all non-abstract classes that extend this trait must provide an implementation of these services.”

What you might also do at this point is imagine that you’re the consumer of this API. When you do that, it helps to sketch out some sample “consumer” code to make sure the API looks like what you want:

val p = Pizza(Small, Thin, Seq(Cheese))

// how you want to use the methods in PizzaServiceInterface
val p1 = addTopping(p, Pepperoni)
val p2 = addTopping(p1, Onions)
val p3 = updateCrustType(p2, Thick)
val p4 = updateCrustSize(p3, Large)

If that code seems okay, you’ll typically start sketching another API—such as an API for orders—but since we’re only looking at pizzas right now, we’ll stop thinking about interfaces and create a concrete implementation of this interface.

Notice that this is usually a two-step process. In the first step, you sketch the contract of your API as an interface. In the second step you create a concrete implementation of that interface. In some cases you’ll end up creating multiple concrete implementations of the base interface.

Creating a concrete implementation

Now that you know what the PizzaServiceInterface looks like, you can create a concrete implementation of it by writing the body for all of the methods you defined in the interface:

object PizzaService extends PizzaServiceInterface {

  def price(p: Pizza): Double =
    ... // implementation from above

  def addTopping(p: Pizza, t: Topping): Pizza =
    p.copy(toppings = p.toppings :+ t)

  def removeAllToppings(p: Pizza): Pizza =
    p.copy(toppings = Seq.empty)

  def updateCrustSize(p: Pizza, cs: CrustSize): Pizza =
    p.copy(crustSize = cs)

  def updateCrustType(p: Pizza, ct: CrustType): Pizza =
    p.copy(crustType = ct)
}
object PizzaService extends PizzaServiceInterface:

  def price(p: Pizza): Double =
    ... // implementation from above

  def addTopping(p: Pizza, t: Topping): Pizza =
    p.copy(toppings = p.toppings :+ t)

  def removeAllToppings(p: Pizza): Pizza =
    p.copy(toppings = Seq.empty)

  def updateCrustSize(p: Pizza, cs: CrustSize): Pizza =
    p.copy(crustSize = cs)

  def updateCrustType(p: Pizza, ct: CrustType): Pizza =
    p.copy(crustType = ct)

end PizzaService

While this two-step process of creating an interface followed by an implementation isn’t always necessary, explicitly thinking about the API and its use is a good approach.

With everything in place you can use your Pizza class and PizzaService:

import PizzaService._

val p = Pizza(Small, Thin, Seq(Cheese))

// use the PizzaService methods
val p1 = addTopping(p, Pepperoni)
val p2 = addTopping(p1, Onions)
val p3 = updateCrustType(p2, Thick)
val p4 = updateCrustSize(p3, Large)

println(price(p4)) // prints 8.75
import PizzaService.*

val p = Pizza(Small, Thin, Seq(Cheese))

// use the PizzaService methods
val p1 = addTopping(p, Pepperoni)
val p2 = addTopping(p1, Onions)
val p3 = updateCrustType(p2, Thick)
val p4 = updateCrustSize(p3, Large)

println(price(p4)) // prints 8.75

Functional Objects

In the book, Programming in Scala, the authors define the term, “Functional Objects” as “objects that do not have any mutable state”. This is also the case for types in scala.collection.immutable. For example, methods on List do not mutate the interal state, but instead create a copy of the List as a result.

You can think of this approach as a “hybrid FP/OOP design” because you:

  • Model the data using immutable case classes.
  • Define the behaviors (methods) in the same type as the data.
  • Implement the behavior as pure functions: They don’t mutate any internal state; rather, they return a copy.

This really is a hybrid approach: like in an OOP design, the methods are encapsulated in the class with the data, but as typical for a FP design, methods are implemented as pure functions that don’t mutate the data

Example

Using this approach, you can directly implement the functionality on pizzas in the case class:

case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
) {

  // the operations on the data model
  def price: Double =
    pizzaPrice(this) // implementation from above

  def addTopping(t: Topping): Pizza =
    this.copy(toppings = this.toppings :+ t)

  def removeAllToppings: Pizza =
    this.copy(toppings = Seq.empty)

  def updateCrustSize(cs: CrustSize): Pizza =
    this.copy(crustSize = cs)

  def updateCrustType(ct: CrustType): Pizza =
    this.copy(crustType = ct)
}
case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
):

  // the operations on the data model
  def price: Double =
    pizzaPrice(this) // implementation from above

  def addTopping(t: Topping): Pizza =
    this.copy(toppings = this.toppings :+ t)

  def removeAllToppings: Pizza =
    this.copy(toppings = Seq.empty)

  def updateCrustSize(cs: CrustSize): Pizza =
    this.copy(crustSize = cs)

  def updateCrustType(ct: CrustType): Pizza =
    this.copy(crustType = ct)

Notice that unlike the previous approaches, because these are methods on the Pizza class, they don’t take a Pizza reference as an input parameter. Instead, they have their own reference to the current pizza instance as this.

Now you can use this new design like this:

Pizza(Small, Thin, Seq(Cheese))
  .addTopping(Pepperoni)
  .updateCrustType(Thick)
  .price

Extension Methods

Finally, we show an approach that lies between the first one (defining functions in the companion object) and the last one (defining functions as methods on the type itself).

Extension methods let us create an API that is like the one of functional object, without having to define functions as methods on the type itself. This can have multiple advantages:

  • Our data model is again very concise and does not mention any behavior.
  • We can equip types with additional methods retroactively without having to change the original definition.
  • Other than companion objects or direct methods on the types, extension methods can be defined externally in another file.

Let us revisit our example once more.

case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
)

implicit class PizzaOps(p: Pizza) {
  def price: Double =
    pizzaPrice(p) // implementation from above

  def addTopping(t: Topping): Pizza =
    p.copy(toppings = p.toppings :+ t)

  def removeAllToppings: Pizza =
    p.copy(toppings = Seq.empty)

  def updateCrustSize(cs: CrustSize): Pizza =
    p.copy(crustSize = cs)

  def updateCrustType(ct: CrustType): Pizza =
    p.copy(crustType = ct)
}

In the above code, we define the different methods on pizzas as methods in an implicit class. With implicit class PizzaOps(p: Pizza) then wherever PizzaOps is imported its methods will be available on instances of Pizza. The receiver in this case is p.

case class Pizza(
  crustSize: CrustSize,
  crustType: CrustType,
  toppings: Seq[Topping]
)

extension (p: Pizza)
  def price: Double =
    pizzaPrice(p) // implementation from above

  def addTopping(t: Topping): Pizza =
    p.copy(toppings = p.toppings :+ t)

  def removeAllToppings: Pizza =
    p.copy(toppings = Seq.empty)

  def updateCrustSize(cs: CrustSize): Pizza =
    p.copy(crustSize = cs)

  def updateCrustType(ct: CrustType): Pizza =
    p.copy(crustType = ct)

In the above code, we define the different methods on pizzas as extension methods. With extension (p: Pizza) we say that we want to make the methods available on instances of Pizza. The reciever in this case is p.

Using our extension methods, we can obtain the same API as before:

Pizza(Small, Thin, Seq(Cheese))
  .addTopping(Pepperoni)
  .updateCrustType(Thick)
  .price

while being able to define extensions in any other module. Typically, if you are the designer of the data model, you will define your extension methods in the companion object. This way, they are already available to all users. Otherwise, extension methods need to be imported explicitly to be usable.

Summary of this Approach

Defining a data model in Scala/FP tends to be simple: Just model variants of the data with enumerations and compound data with case classes. Then, to model the behavior, define functions that operate on values of your data model. We have seen different ways to organize your functions:

  • You can put your methods in companion objects
  • You can use a modular programming style, separating interface and implementation
  • You can use a “functional objects” approach and store the methods on the defined data type
  • You can use extension methods to equip your data model with functionality

Contributors to this page: