Keep your domain clean with Scala 3
Core logic polluted with boilerplate
Concerns from other contexts
Core domain becomes junk drawer
Solve simple problems with simple tools
Powerful for complex problems
Flexible so that you can be precise
User
---------------------------
id : Id
name : Name
email : Email
status : UserStatus
createdAt : Timestamp
---------------------------
UserStatus:
Active / Pending / Suspendedpackage 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
}case class Name(value: String) extends AnyVal {
def toAirlineSafe: String = value.map {
case 'ą' | 'Ą' => 'a'
case 'ł' | 'Ł' => 'l'
// ...
case other => other
}
}makeReservation(name.toAirlineSafe)Usage
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
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
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
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}>"case class Name(value: String) extends AnyVal // 🚨printDiploma("usr_123") //❌ should not compileUsage:
opaque type Name = Stringpackage core
val name = Name("Magda Stożek")
printDiploma(name)Usage
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 type Name = String
object Name:
def apply(value: String): Name = value
extension (name: Name) def asString: String = nameopaque type Name = String
object Name:
def from(value: String): Option[Name] =
Option.when(value.trim.nonEmpty)(value)
extension (name: Name) def asString: String = namesealed trait UserStatus
object UserStatus {
case object Pending extends UserStatus
case object Active extends UserStatus
case class Suspended(reason: String) extends UserStatus
}enum UserStatus:
case Pending, Active
case Suspended(reason: String)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")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]package search
import core.{User, Department}
type SearchResult = User | Department
def search(query: String): List[SearchResult]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)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]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")Code quality is still our responsibility
Boundaries are key
AnyVal
implicit class
sealed trait
union types
opaque types
extension method
enum
common trait
Feedback + slides: