Flexible modelling

Magda Stożek

Keep your domain pure with Scala 3

The dream

The dream

The reality

The reality

Core logic polluted with

boilerplate/language internals

Infrastructure concerns

Steep learning curve

Scala 3

Lets you solve easy problems with easy tools

Extendable so that it's powerful for complex problems

Flexible so it lets you express what you want, where you want

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 AirlineSafeName(name: Name) {
  def format: String = name.value.map {
    case 'ą' | 'Ą' => 'a'
    case 'ł' | 'Ł' => 'l'
    case other => other
  }
}
val nameForAirline = new AirlineSafeName(user.name)
makeReservation(nameForAirline.toAirlineSafe)

Usage

Workaround - Implicit class

package airline
import core.Name

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

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

user.name.toAirlineSafe

Usage

The Fix - Extensions

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}>"

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
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!

The Fix: Opaque types

opaque type Name = String

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

Opaque types - 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

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")

Act 4 - The API Leak (Before)

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 // 🚨 

Act 4 - The Fix (Union Types)

package search
import core.{User, Department}


type SearchResult = User | Department

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

The Pure 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 boundaries

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}>"

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

opaque type Email = String
object Email:
  def from(value: String): Option[Email] = 
    Option.when(value.contains("@"))(value)
  
  extension (email: Email) def asString: String = email
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")

Tools

AnyVal -> opaque types

implicit class -> extension method

sealed trait -> enum

sealed trait -> union types

Hand-written or AI?

Code quality is still our responsibility

Boundaries are key

Summary

Simple tools for simple problems

Extendable and powerful

Helps keep architecture boundaries

Thank you

Flexible modelling

By Magda Stożek

Flexible modelling

  • 34