Domain modeling in Scala

Domain-Driven Design

  • models (data)

    • values

    • entities

    • events

  • behaviors

    • factories

    • repositories

    • services

Models

Value

An immutable object which is only distinguishable by its properties.

val age:  Int    = 18
val name: String = "John Smith"
val cash: Double = 14.56
age == age // true
age == (age + 1) // false

name == "John Smith" // true
name == "John Doe" // false

cash == 14.56 // true
cash == 14.57 // false
val accountDetails: (Int, String, Double) = (age, name, cash)

accountDetails == accountDetails // true
accountDetails == accountDetails.copy(_1 = 19) // false
type Age = Int
type Name = String
type Cash = Double
val age:  Age  = 18
val name: Name = "John Smith"
val cash: Cash = 14.56
case class AccountDetails(name: Name, age: Age, cash: Cash)
val details = AccountDetails("John Smith", 18, 14.56)

details == details // true
details == details.copy(age = 19) // false
type Name    = String
type Surname = String

val name:    Name    = "John"
val surname: Surname = name // :(
case class Name(value: String) extends AnyVal
case class Surname(value: String) extends AnyVal

val name:    Name    = Name("John")
val surname: Surname = name // compiler error :)
case class Email(value: String) extends AnyVal

sealed trait EmailStatus
object EmailStatus {

  case class Confirming(unconfirmed: Email)
      extends EmailStatus

  case class Confirmed(confirmed: Email)
      extends EmailStatus

  case class Changing(old: Email, newUnconfirmed: Email)
      extends EmailStatus
}
val emailStatus: EmailStatus = ...

emailStatus match {
  case EmailStatus.Confirming(unconfirmed) =>
    println(s"Email $unconfirmed is not confirmed yet")
  case EmailStatus.Confirmed(confirmed) =>
    println(s"Email is confirmed $confirmed")
  case EmailStatus.Changing(old, newUnconfirmed) =>
    println(
      s"Old email $old is confirmed" +
        s" but user requested change $newUnconfirmed"
    )
}

Entity

An object which has some intrinsic identity, which allows tracing its state’s changes in time.

case class UserId(value: UUID) extends AnyVal
case class Name(value: String) extends AnyVal
case class Surname(value: String) extends AnyVal
case class UserData(name: Name, surname: Surname)

case class User(id: UserId, data: UserData) {

  override def equals(obj: Any): Boolean = obj match {
    case User(otherId, _) => id == otherId
    case _                => false
  }

  override def hashCode: Int = id.hashcode
}
val userId = UserId(UUID.randomUUID)
val data = UserData(Name("John"), Surname("Smith"))
val user = User(userId, data)

user == user // true

import com.softwaremill.quicklens._
user == user.modify(_.data.name).set(Name("Jane")) // true

user == user.copy(id = UserId(UUID.randomUUID)) // false

Event

An information that something happened in the system.

case class UserCreationRequested(
  data: UserData
)

case class UserCreated(
  userId: UserId,
  data: UserData,
  at: Instant
)
val createUser: UserCreationRequested => UserCreated

Behaviors

Side-effect free

sealed abstract case class PlanName(value: String) {
  def +(another: PlanName): PlanName =
    new PlanName(value + " " + another.value) {}
}
object PlanName {
  def parse(value: String): Either[String, PlanName] =
    if (value.trim.isEmpty) {
      Left(s"'$value' is not a valid plan name")
    } else Right(new PlanName(value.trim) {})
}
import cats.implicits._
val names: Either[String, (PlanName, PlanName)] =
  (PlanName.parse("personal"), PlanName.parse("liability"))
    .tupled
names match {
  case Right((name1, name2)) =>
    println(s"validation succeeded: ${plan1 + plan2}")
  case Left(error) =>
    println(error)
}
// validation succeeded: personal liability
val version2: PlanName = ... // PlanName("version 2")
PlanName.parse("household").flatMap { name1 =>
  PlanName.parse(name1.value + " insurance").map { name2 =>
    name2 + version2
  }
}
// Right(PlanName("household insurance version 2"))
for {
  name1 <- PlanName.parse("household")
  name2 <- PlanName.parse(name1.value + " insurance")
} yield name2 + version2

Side effects

import cats.effect._

val program = IO.delay(scala.io.StdIn.readLine) // IO[String]
  .map(PlanName.parse) // IO[Either[String, PlanName]]
  .flatMap {
    case Left(error) => IO.raiseError(new Exception(error))
    case Right(name) => IO.pure(name)
  } // IO[PlanName]
  .map { name =>
    println(s"Valid plan name: $name")
  } // IO[Unit]
  .handleError {
    case e: Throwable => e.printStackTrace()
  } // IO[Unit]
program.unsafeRunSync // Unit
program.unsafeRunAsync { result =>
  println(s"Program finished with: $result")
} // Unit
program.unsafeToFuture // Future[Unit]
import scala.concurrent.duration._

IO.delay(scala.io.StdIn.readLine)
  .flatMap { in =>
     val nap      = IO.sleep(2.second)
     val asBool   = nap >> IO.delay(in.toBoolean).attempt
     val asInt    = nap >> IO.delay(in.toInt).attempt
     val asDouble = nap >> IO.delay(in.toDouble).attempt
     (asBool, asInt, asDouble).parMapN { (b, i, d) =>
       println(s"$in:\nbool: $b,\nint: $i,\ndouble: $d")
     }
  }
  .unsafeRunAsync(result => println(result))

Services

Plan’s subdomain

case class PlanId(value: UUID) extends AnyVal
case class PlanVersion(value: Int) extends AnyVal
case class PlanVersionedId(id: PlanId, version: PlanVersion)

sealed trait PlanStatus
object PlanStatus {
  case object NotLaunched extends PlanStatus
  case class Launched(at: Instant) extends PlanStatus
  case class Retired(validFrom: Instant, validUntil: Instant)
    extends PlanStatus
}

case class PlanData(name: PlanName, status: PlanStatus)
case class Plan(versionedId: PlanVersionedId, data: PlanData){
  override def equals(obj: Any): Boolean = obj match {
    case Plan(`versionedId`, _) => true
    case _                      => false
  }
  override def hashCode: Int = versionedId.hashCode
}
trait PlanServices {

  def createPlan(data: PlanData): IO[Plan]
  def updatePlan(id: PlanId, data: PlanData): IO[Plan]
  def launchPlan(versionedId: PlanVersionedId,
                 at: Instant): IO[Plan]
  def retirePlan(versionedId: PlanVersionedId,
                 at: Instant): IO[Plan]
}
trait PlanRepository {

  def listActivePlans: IO[List[Plan]]
  def listPlanVersions(id: PlanId): IO[List[Plan]]
  def getExactPlan(versionedId: PlanVersionedId): IO[Plan]
  def getLastestPlan(id: PlanId): IO[Plan]
}
val planName = PlanName.parse("my plan").right.get
val planLifecycleTest = for {
  plan1 <- planServices.createPlan(
      PlanData(planName, PlanStatus.NotLaunched))
  _ <- planServices.launchPlan(plan1.versionedId, Instant.now)
  _ <- IO.sleep(1.second)
  active <- planRepository.listActivePlans
  _ = println("Active plans\n" + active.mkString("\n"))
  retired <- planServices.retirePlan(plan1.versionedId,
                                     Instant.now)
} yield retired
val planVersioningTest = for {
  oldPlan <- planLifecycleTest
  plan2 <- planServices.updatePlan(
      oldPlan.versionedId.id,
      PlanData(oldPlan.data.name, PlanStatus.NotLaunched))
  versions <- planRepository.listPlanVersions(
      plan2.versionedId.id)
  _ = println("Versions\n" + versions.mkString("\n"))
} yield versions
planVersioningTest.unsafeRunSync

Contract’s subdomain

case class CustomerId(value: UUID) extends AnyVal
case class ContractId(value: UUID) extends AnyVal
sealed trait ContractDuration
object ContractDuration {
  case class Fixed(from: ZonedDate, until: Instant)
    extends ContractDuration
  case class Renewed(from: Instant, lastRenewal: Instant,
                     terminated: Option[Instant])
    extends ContractDuration
}
case class Coverage(start: Instant, end: Instant)
case class ContractData(
  customerId: CustomerId,       planVersion: PlanVersionedId
  duration:   ContractDuration, coverage:    Coverage
)
case class Contract(id: ContractId, data: ContractData) {
  override def equals(obj: Any): Boolean = obj match {
    case Plan(`id`, _) => true
    case _             => false
  }
  override def hashCode: Int = versionedId.hashCode
}
trait ContractServices {

  def createContract(data: ContractData): IO[Contract]
  def renewContract(
        id: ContractId,
        nextRenewal: Instant,
        nextCoverageEnd: Instant): IO[List[Contract]]
  def terminateContract(id: ContractId,
                        at: Instant): IO[Contract]
}
trait ContractRepository {

  def getContract(id: ContractId): IO[Contract]
  def listContractsForCustomer(
        customerId: CustomerId): IO[List[Contract]]
  def listContractsForRenewal(at: Instant): IO[List[Contract]]
}
sealed trait ContractEvent
case class ContractCreated(id: ContractId)
    extends ContractEvent
case class ContractRenewed(id: ContractId)
    extends ContractEvent
case class ContractTerminated(id: ContractId)
    extends ContractEvent

Customer’s subdomain

case class CustomerData(
  name:      CustomerName,
  surname:   CustomerSurname,
  addresses: NonEmptyList[Address],
  email:     Email
)
case class Customer(id: CustomerId, data: CustomerData) {
  // ... override equals and hashCode
}
trait CustomerRepository {
  def getCustomerById(id: CustomerId): IO[Customer]
}

Payment’s domain

case class PaymentId(value: UUID) extends AnyVal
sealed trait PaymentType
object PaymentType {
  case object SinglePayment extends PaymentType
  case class Subscription(renewEvery: Duration)
      extends PaymentType
}
sealed trait PaymentStatus
object PaymentStatus {
  case object Scheduled extends PaymentStatus
  case object Completed extends PaymentStatus
  case object Failed extends PaymentStatus
  case object Cancelled extends PaymentStatus
}
sealed trait PaymentMethod
object PaymentMethod {
  case class CreditCard(...) extends PaymentMethod
  case class Stripe(...) extends PaymentMethod
  case class PayPal(...) extends PaymentMethod
  ...
}
case class PaymentData(
  contractId:      ContractId,
  paymentType:     PaymentType,
  paymentMethod:   PaymentMethod,
  status:          PaymentStatus,
  amount:          Money,
  customerId:      CustomerId,
  customerName:    CustomerName,
  customerSurname: CustomerSurname,
  invoiceAddress:  Address
)
case class Payment(id: PaymentId, data: PaymentData) {
  // ... override equals and hashCode
}
import squants.market._

trait QuotingServices {
  def quoteForContract(
        data: ContractData): IO[(Money, PaymentType)]
}
trait CustomerPaymentMethodServices {
  def setMethodForCustomer(
        customerId: CustomerId,
        paymentMethod: PaymentMethod): IO[Unit]
  def getMethodForCustomer(
        customerId: CustomerId): IO[PaymentMethod]
}
trait PaymentServices {
  def schedulePayment(data: PaymentData): IO[Payment]
  def renewPayment(paymentId: PaymentId,
                   at: Instant): IO[Payment]
  def retryPayment(
        paymentId: PaymentId,
        newMethod: Option[PaymentMethod]): IO[Payment]
}

Event sourcing

def createPayment(contractId: ContractId) = for {
  contract <- contractRepository.getContract(contractId)
  customerId = contractData.customerId
  customer <- customerRepository.getCustomerById(customerId)
  method <- customerPaymentMethodServices
    .getMethodForCustomer(customerId)
  (price, paymentType) <-
    quotingServices.quoteForContract(contract.data)
  data = PaymentData(contractId, paymentType, method,
    PaymentStatus.Scheduled, price, customerId,
    customerData.name, customerData.surname,
    customerData.addresses.head)
  payment <- paymentServices.createPayment(data)
} yield payment
import fs2._

val contractEvents: Stream[IO, ContractEvent] = ...

val paymentsCreationProjection: IO[Unit] =
  contractEvent
    .collect {
      case ContractCreated(contractId) => contractId
    }
    .evalMap(createPayment)
    .drain.compile
val fiber: IO[Fiber[IO, Unit]] =
  paymentsCreationProjection.start
val projections = for {
  _ <- paymentsCreationProjection.start
  _ <- paymentsRenewedProjection.start
  _ <- paymentsCancelledProjection.start
  _ = println("All projections started")
} yield ()

Other interesting things that we didn’t have space for

Some E2E examples of services in Cats

Summary

Questions?

Thank you