Scala 3 — Book

Domain Modeling

Language

Scala supports both functional programming (FP) and object-oriented programming (OOP), as well as a fusion of the two paradigms. This section provides a quick overview of data modeling in OOP and FP.

OOP Domain Modeling

When writing code in an OOP style, your two main tools for data encapsulation are traits and classes.

Traits

Scala traits can be used as simple interfaces, but they can also contain abstract and concrete methods and fields, and they can have parameters, just like classes. They provide a great way for you to organize behaviors into small, modular units. Later, when you want to create concrete implementations of attributes and behaviors, classes and objects can extend traits, mixing in as many traits as needed to achieve the desired behavior.

As an example of how to use traits as interfaces, here are three traits that define well-organized and modular behaviors for animals like dogs and cats:

trait Speaker {
  def speak(): String  // has no body, so it’s abstract
}

trait TailWagger {
  def startTail(): Unit = println("tail is wagging")
  def stopTail(): Unit = println("tail is stopped")
}

trait Runner {
  def startRunning(): Unit = println("I’m running")
  def stopRunning(): Unit = println("Stopped running")
}
trait Speaker:
  def speak(): String  // has no body, so it’s abstract

trait TailWagger:
  def startTail(): Unit = println("tail is wagging")
  def stopTail(): Unit = println("tail is stopped")

trait Runner:
  def startRunning(): Unit = println("I’m running")
  def stopRunning(): Unit = println("Stopped running")

Given those traits, here’s a Dog class that extends all of those traits while providing a behavior for the abstract speak method:

class Dog(name: String) extends Speaker with TailWagger with Runner {
  def speak(): String = "Woof!"
}
class Dog(name: String) extends Speaker, TailWagger, Runner:
  def speak(): String = "Woof!"

Notice how the class extends the traits with the extends keyword.

Similarly, here’s a Cat class that implements those same traits while also overriding two of the concrete methods it inherits:

class Cat(name: String) extends Speaker with TailWagger with Runner {
  def speak(): String = "Meow"
  override def startRunning(): Unit = println("Yeah ... I don’t run")
  override def stopRunning(): Unit = println("No need to stop")
}
class Cat(name: String) extends Speaker, TailWagger, Runner:
  def speak(): String = "Meow"
  override def startRunning(): Unit = println("Yeah ... I don’t run")
  override def stopRunning(): Unit = println("No need to stop")

These examples show how those classes are used:

val d = new Dog("Rover")
println(d.speak())      // prints "Woof!"

val c = new Cat("Morris")
println(c.speak())      // "Meow"
c.startRunning()        // "Yeah ... I don’t run"
c.stopRunning()         // "No need to stop"
val d = Dog("Rover")
println(d.speak())      // prints "Woof!"

val c = Cat("Morris")
println(c.speak())      // "Meow"
c.startRunning()        // "Yeah ... I don’t run"
c.stopRunning()         // "No need to stop"

If that code makes sense—great, you’re comfortable with traits as interfaces. If not, don’t worry, they’re explained in more detail in the Domain Modeling chapter.

Classes

Scala classes are used in OOP-style programming. Here’s an example of a class that models a “person.” In OOP, fields are typically mutable, so firstName and lastName are both declared as var parameters:

class Person(var firstName: String, var lastName: String) {
  def printFullName() = println(s"$firstName $lastName")
}

val p = new Person("John", "Stephens")
println(p.firstName)   // "John"
p.lastName = "Legend"
p.printFullName()      // "John Legend"
class Person(var firstName: String, var lastName: String):
  def printFullName() = println(s"$firstName $lastName")

val p = Person("John", "Stephens")
println(p.firstName)   // "John"
p.lastName = "Legend"
p.printFullName()      // "John Legend"

Notice that the class declaration creates a constructor:

// this code uses that constructor
val p = new Person("John", "Stephens")
// this code uses that constructor
val p = Person("John", "Stephens")

Constructors and other class-related topics are covered in the Domain Modeling chapter.

FP Domain Modeling

When writing code in an FP style, you’ll use these concepts:

  • Algebraic Data Types to define the data
  • Traits for functionality on the data.

Enumerations and Sum Types

Sum types are one way to model algebraic data types (ADTs) in Scala.

They are used when data can be represented with different choices.

For instance, a pizza has three main attributes:

  • Crust size
  • Crust type
  • Toppings

These are concisely modeled with enumerations, which are sum types that only contain singleton values:

In Scala 2 sealed classes and case object are combined to define an enumeration:

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
}

Scala 3 offers the enum construct for defining enumerations:

enum CrustSize:
  case Small, Medium, Large

enum CrustType:
  case Thin, Thick, Regular

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

Once you have an enumeration you can import its members as ordinary values:

import CrustSize._
val currentCrustSize = Small

// enums in a `match` expression
currentCrustSize match {
  case Small => println("Small crust size")
  case Medium => println("Medium crust size")
  case Large => println("Large crust size")
}

// enums in an `if` statement
if (currentCrustSize == Small) println("Small crust size")
import CrustSize.*
val currentCrustSize = Small

// enums in a `match` expression
currentCrustSize match
  case Small => println("Small crust size")
  case Medium => println("Medium crust size")
  case Large => println("Large crust size")

// enums in an `if` statement
if currentCrustSize == Small then println("Small crust size")

Here’s another example of how to create a sum type with Scala, this would not be called an enumeration because the Succ case has parameters:

sealed abstract class Nat
object Nat {
  case object Zero extends Nat
  case class Succ(pred: Nat) extends Nat
}

Sum Types are covered in detail in the Domain Modeling section of this book.

enum Nat:
  case Zero
  case Succ(pred: Nat)

Enums are covered in detail in the Domain Modeling section of this book, and in the Reference documentation.

Product Types

A product type is an algebraic data type (ADT) that only has one shape, for example a singleton object, represented in Scala by a case object; or an immutable structure with accessible fields, represented by a case class.

A case class has all of the functionality of a class, and also has additional features baked in that make them useful for functional programming. When the compiler sees the case keyword in front of a class it has these effects and benefits:

  • Case class constructor parameters are public val fields by default, so the fields are immutable, and accessor methods are generated for each parameter.
  • An unapply method is generated, which lets you use case classes in more ways in match expressions.
  • A copy method is generated in the class. This provides a way to create updated copies of the object without changing the original object.
  • equals and hashCode methods are generated to implement structural equality.
  • A default toString method is generated, which is helpful for debugging.

You can manually add all of those methods to a class yourself, but since those features are so commonly used in functional programming, using a case class is much more convenient.

This code demonstrates several case class features:

// define a case class
case class Person(
  name: String,
  vocation: String
)

// create an instance of the case class
val p = Person("Reginald Kenneth Dwight", "Singer")

// a good default toString method
p                // : Person = Person(Reginald Kenneth Dwight,Singer)

// can access its fields, which are immutable
p.name           // "Reginald Kenneth Dwight"
p.name = "Joe"   // error: can’t reassign a val field

// when you need to make a change, use the `copy` method
// to “update as you copy”
val p2 = p.copy(name = "Elton John")
p2               // : Person = Person(Elton John,Singer)

See the Domain Modeling sections for many more details on case classes.

Contributors to this page: