Flexible modelling

Magda Stożek

Keep your domain clean with Scala 3

The dream

The dream

The reality

The reality

Core logic polluted with boilerplate

Concerns from other contexts

Core domain becomes junk drawer

Scala 3

Solve simple problems with simple tools

Powerful for complex problems

Flexible so that you can be precise

Idea

User
---------------------------
  id          : Id
  name        : Name
  email       : Email
  status      : UserStatus
  createdAt   : Timestamp
---------------------------
UserStatus: 
  Active / Pending / Suspended

The problem

package core

import search.SearchResult // 🚨

case class User(
  id: Id, name: Name, email: Email, status: UserStatus, createdAt: Instant
) extends SearchResult { // 🚨
  
  def searchLabel: String = s"${name.value} <${email.value}>" // 🚨
  
  def toJson: String = 
    s"""{"id":"${id.value}","name":"${name.value}","status":"$status"}"""  // 🚨
}

case class Name(value: String) extends AnyVal { // 🚨
  def toAirlineSafe: String = value.map { // 🚨 
    case 'ą' | 'Ą' => 'a'
    case 'ż' | 'Ż' | 'ź' | 'Ź' => 'z'
    // (...)
  }
}
  
sealed trait UserStatus // 🚨 
object UserStatus {
  case object Pending extends UserStatus
  case object Active extends UserStatus
  case class Suspended(reason: String) extends UserStatus
}

Issue 1 - The Integration Leak 

case class Name(value: String) extends AnyVal {
  
  def toAirlineSafe: String = value.map {
    case 'ą' | 'Ą' => 'a'
    case 'ł' | 'Ł' => 'l'
    // ... 
    case other => other
  }
}
makeReservation(name.toAirlineSafe)

Usage

Workaround - Wrapper class 

package airline
import core.Name

class AirlineName(name: Name) {
  def toAirlineSafe: String = name.value.map {
    case 'ą' | 'Ą' => 'a'
    case 'ł' | 'Ł' => 'l'
    case other => other
  }
}
val name = new AirlineName(user.name)
makeReservation(name.toAirlineSafe)

Usage

Workaround - Implicit class

package airline
import core.Name

object Formatters {
  implicit class NameOps(name: Name) {
    def toAirlineSafe: String = name.value.map {
      case 'ą' | 'Ą' => 'a'
      case 'ł' | 'Ł' => 'l'
      case other => other
    }
  }
}
import airline.Formatters._

makeReservation(user.name.toAirlineSafe)

Usage

The Fix - Extensions

package airline
import core.Name

extension (name: Name)
  def toAirlineSafe: String = name.asString.map {
    case 'ą' | 'Ą' => 'a'
    case 'ł' | 'Ł' => 'l'
    // ...
    case other => other
  }
import airline.toAirlineSafe

makeReservation(user.name.toAirlineSafe)

Usage

Extensions - contexts

package api
import core.User

extension (user: User)
  def toJson: String = 
    s"""{"id":"${user.id.value}","name":"${user.name.value}"}"""
package search
import core.User

extension (user: User)
  def searchLabel: String = s"${user.name.value} <${user.email.value}>"

Issue 2: The performance leak

case class Name(value: String) extends AnyVal // 🚨
printDiploma("usr_123") //❌ should not compile

Usage:

The fix: Opaque types

opaque type Name = String
package core
val name = Name("Magda Stożek")
printDiploma(name)

Usage

The fix: Opaque types

opaque type Name = String
package core
val name = Name("Magda Stożek")
printDiploma(name)

Usage

package diplomas

import core.Name

val name = Name("Magda Stożek") //❌ Not found: Name
printDiploma(name)

But!

Opaque types - API

opaque type Name = String

object Name:
  def apply(value: String): Name = value
  
extension (name: Name) def asString: String = name

Opaque types - smart constructor

opaque type Name = String

object Name:
  def from(value: String): Option[Name] = 
    Option.when(value.trim.nonEmpty)(value)
  
extension (name: Name) def asString: String = name

Issue 3 - The Noise

sealed trait UserStatus
object UserStatus {
  case object Pending extends UserStatus
  case object Active extends UserStatus
  case class Suspended(reason: String) extends UserStatus
}

The fix - Enums

enum UserStatus:
  case Pending, Active
  case Suspended(reason: String)

Enums - extended

enum UserStatus(val canAuthenticate: Boolean):
  
  case Pending extends UserStatus(canAuthenticate = false)
  case Active  extends UserStatus(canAuthenticate = true)
  case Suspended(reason: String) extends UserStatus(canAuthenticate = false)

  def loginRejectionReason: Option[String] = this match
    case Active       => None
    case Pending      => Some("Account is awaiting email verification.")
    case Suspended(r) => Some(s"Account suspended: $r")

Issue 4 - The API Leak

package core
import search.SearchResult // 🚨

case class User(
  id: Id, 
  name: Name, 
  email: Email, 
  status: UserStatus, 
  createdAt: Instant
) extends SearchResult // 🚨

case class Department(name: String) extends SearchResult // 🚨 
package search

trait SearchResult

def search(query: String): List[SearchResult]

The Fix - Union Types

package search
import core.{User, Department}


type SearchResult = User | Department

def search(query: String): List[SearchResult]

The Clean Domain

package core
import java.time.Instant

case class User(id: Id, name: Name, email: Email, status: UserStatus, createdAt: Instant)
case class Department(name: String)

opaque type Id = String

opaque type Name = String

opaque type Email = String

enum UserStatus:
  case Pending, Active
  case Suspended(reason: String)

The edges

package api
import core.User

extension (user: User)
  def toJson: String = 
    s"""{"id":"${user.id.asString}","name":"${user.name.asString}"}"""
package search
import core.User

extension (user: User)
  def searchLabel: String = s"${name.value} <${email.value}>"


type SearchResult = User | Department

def search(query: String): List[SearchResult]

Domain extended

opaque type Name = String
object Name:
  def from(value: String): Option[Name] = 
    Option.when(value.trim.nonEmpty)(value)
  
  extension (name: Name) def asString: String = name
enum UserStatus(val canAuthenticate: Boolean):
  
  case Pending extends UserStatus(canAuthenticate = false)
  case Active  extends UserStatus(canAuthenticate = true)
  case Suspended(reason: String) extends UserStatus(canAuthenticate = false)

  def loginRejectionReason: Option[String] = this match
    case Active       => None
    case Pending      => Some("Account is awaiting email verification.")
    case Suspended(r) => Some(s"Account suspended: $r")

Hand-written or AI?

Code quality is still our responsibility

Boundaries are key

Summary

AnyVal

implicit class

sealed trait

union types

opaque types

extension method

enum

common trait

Thank you

Feedback + slides: