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 / SuspendedThe 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 compileUsage:
The fix: Opaque types
opaque type Name = Stringpackage core
val name = Name("Magda Stożek")
printDiploma(name)Usage
The fix: Opaque types
opaque type Name = Stringpackage 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 = nameOpaque 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 = nameIssue 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 = nameenum 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:
Flexible modelling
By Magda Stożek
Flexible modelling
- 255
