A few tips on modelling things in Scala

Mateusz Kubuszok

About me

The plan

  • case class es - when, when not

  • flat is (often) bad

  • sealed trait s - not only enums

  • one size suits all - doesn’t work

  • integrations

case class es

Warm up

What we get when we do?

case class Value(a: Int, b: String)
class Value(a: Int, b: String)
    extends Product with Serializable {
  def copy(a: Int = a, b: String = b): Value = ...
  override def equals(obj: AnyRef): Boolean = ...
  override def hashCode(): Int = ...
  override def toString(): String = s"Value($a, $b)"
  // ... a lot of methods from Product
}
object Value extends ((Int, String) => Value) {
  def apply(a: Int, b: String): Value = new Value(a, b)
  def unapply(value: Value): Option[(Int, String)] = ...
}
Value(1, "1") == new Value(1, "1") // true
Value(1, "1") == Value(2, "2") // false
Value(1, "1") == Value(1, "2").copy(b = "1") // true

Value(1, "1") match {
  case Value(a, b) => println(s"a: $a, b: $b")
}

When

  • we want to represent a value/record

  • all properties are value types

When not

When there is state

case class NamedCounter(name: String,
                        var counter: Int = 0) {
  def action(): Unit = counter += 1
}
val counter  = NamedCounter("test")
val counters = scala.collection.immutable.HashSet(counter)
counters(counter) // true
counter.action()
counters(counter) // false
class NamedCounter(val name: String) {
  var counter: Int = 0
  def action(): Unit = counter += 1
}

When class describes behavior - especially side effects

case class DbConnection(user: String,
                        pass: String,
                        url:  String) {
  // perhaps initialize connection?
  def runSql(query: String): Int = ...
}
case class DbConfig(user: String,
                    pass: String,
                    url:  String)

class DbConnection(dbCfg: DbConfig) {
  // behavior
}

When not all properties are values

case class KeyPair(publicKey: Array[Byte],
                   privateKey: Array[Byte])
case class KeyPair(publicKey: ArraySeq[Byte],
                   privateKey: ArraySeq[Byte])

Cats Typeclasses

  • default Eq should match .equals

  • do not use Show for any business logic, only debugging (same for .toString)

Flat is (often) bad

Flat entity

case class UserAddress(
  id:           UUID,
  userID:       UUID,
  addressLine1: String,
  addressLine2: Option[String],
  city:         String,
  zipCode:      String
)

Comparison

// check for the same entity and version
address1 == address2

// check for the same entity, maybe different version
address1.id == address2.id

// check for the same address value, maybe different entity
(address1.addressLine1 == address2.addressLine1) &&
  (address1.addressLine2 == address2.addressLine2) &&
  (address1.city == address2.city) &&
  (address1.zipCode == address2.zipCode)

Just data things

case class DataRecord(
  name1: String,
  name2: String,
  yetAnotherName: String,
  // ... 20-30 fields more
  metricX: Int,
  metricY: Int
)

Nested entity

final case class UserAddress(
  id:   UserAddress.ID,
  data: UserAddress.Data)
object UserAddress {
  type ID = UUID // or AnyVal or tagged or @newtype
  final case class Data(
    userID:  User.ID,
    address: Address)
}
final case class Address(
  addressLine1: String, // other candidates for:
  addressLine2: Option[String], // * type aliases
  city:         String,         // * AnyVals or
  zipCode:      String)         // * @newtypes

Comparison again

userAddress1 == userAddress2

userAddress1.id == userAddress2.id

userAddress1.data.userID == userAddress2.data.userID

userAddress1.data.address == userAddress2.data.address

Why people avoid nesting

Because .copy

userAddress.copy(
  data = userAddress.data.copy(
    address = userAddress.data.address.copy(city = "Yolo")
  )
)

Unnecessarily

import com.softwaremill.quicklens._ // \o/ \o/ \o/

userAddress.modify(_.data.address).setTo("Yolo")
userAddress.modify(_.data.address).using(_.toUppercase)

sealed trait s

Enums

sealed trait Color
object Color {
  case object Red extends Color
  case object Blue extends Color
  case object Green extends Color
}
(color: Color) match {
  case Color.Red   =>
  case Color.Blue  =>
  case Color.Green =>
}

Enumerating valid combinations

case class UserEmail(currentEmail: String,
                     newEmail:     Option[String] = None,
                     confirmed:    Boolean        = false)
sealed trait UserEmail
object UserEmail {
  case class New(email: String) extends UserEmail
  case class Confirmed(email: String) extends UserEmail
  case class Changing(currentEmail: String,
                      newEmail: String) extends UserEmail
}
enum UserEmail {
  case New(email: String) extends UserEmail
  case Confirmed(email: String) extends UserEmail
  case Changing(currentEmail: String,
                newEmail: String) extends UserEmail
}

If sealed trait doesn’t work for some reason

sealed abstract case class UserEmail private (
  currentEmail: String,
  newEmail:     Option[String],
  confirmed:    Boolean
)
object UserEmail {
  def parse(
    currentEmail: String,
    newEmail:     Option[String] = None,
    confirmed:    Boolean        = false
  ): Either[String, UserEmail] =
    if (/* validation */) Right(new UserEmail(...) {})
    else Left("Illegal combination of parameters")
}

A case against uniform modelling

Uniform modelling:

  • design a model

  • use if for business logic

  • and persistence

  • and API

Uniform modelling in practice:

  • design database tables and some objects mapping to them directly

  • bend over backwards to manually write JSON codecs for these objects

  • define domain in terms of table rows

  • alternatively, start with API

  • try to shove it to database and back

Database Driven Design

users:
+---------+------+-------+-----------+-----------------+
| user_id | name | email | email_new | email_confirmed |
+---------+------+-------+-----------+-----------------+
| uuid    | text | text  | text      | boolean         |
+---------+------+-------+-----------+-----------------+

addresses:
+------------+---------+--------+--------+------+------+
| address_id | user_id | line_1 | line_2 | city | zip  |
+------------+---------+--------+--------+------+------+
| uuid       | uuid    | text   | test   | text | text |
+------------+---------+--------+--------+------+------+
case class User(id:             UUID,
                name:           String,
                email:          String,
                newEmail:       Option[String],
                emailConfirmed: Boolean)

case class Address(id:       UUID,
                   userID:   UUID,
                   position: Int,
                   line1:    String,
                   line2:    Option[String],
                   city:     String,
                   zip:      String)
// Doobie

sql"""INSERT INTO users (
     |  id, name, email, email_new, email_confirmed
     |)
     |VALUE (
     |  ${user.id}, ${user.name}, ${user.email},
     |  ${user.newEmail}, ${user.emailConfirmed}
     |)""".stripMargin.update

sql"""SELECT id, name, email, email_new, email_confirmed
     |FROM users
     |WHERE id = $userID""".stripMargin.query[User]
// Slick

class Users(tag: Tag) extends TableQuery[User] {
  // id, name, email, newEmail, emailConfirmed columns
  def * = (id, name, email, newEmail, emailConfirmed) <> (
    User.tupled, User.unapply
  )
}
{
  "user": {
    "id": "...",
    "name": "...",
    "addresses": [ { "address_id": "...", ... } ]
  }
}
implicit val encoder: Encoder[(User, List[Address])] =
  // manual mapping of models to JSON AST
implicit val decoder: Decoder[(User, List[Address])] =
  // manual mapping of JSON cursor to models
def setPrimaryAddress(user: User, address: Address) =
  for {
    _ <- addressBelongToUser(address, user.id)
    addresses <- getAddressesByUser(user.id)
    newAddresses = addresses
      .filterNot(_.id == address.id)
      .prepend(address)
      .zipWithIndex
      .map { case (a, i) => a.copy(position = i) }
    _ <- newAddresses.traverse(persistAddress)
  } yield newAddresses.head

Separate models

case class User(id:   User.ID,
                data: User.Data)
object User {
  case class Data(name:      User.Name,
                  email:     User.Email,
                  addresses: List[Address])
  // also ID, Name, Email and whetever we need
}

case class Address(line1: Address.Line1,
                   line2: Option[Address.Line2],
                   city:  Address.City,
                   zip:   Address.Zip)
object Address { // Line1, Line2, City, Zip }
case class AddressDTO(userID: UUID,
                      index:  Int,
                      line1:  String,
                      line2:  Option[String],
                      city:   String,
                      zip:    String) {
  def toDomain: Address = // might be Either if we validate
    ...
}
object AddressDTO {
  def fromDomain(addrs: List[Address]): List[AddressDTO] =
    ...
}
case class UserDTO(id:             UUID,
                   name:           String,
                   email:          String,
                   newEmail:       Option[String],
                   emailConfirmed: Boolean) {
  def toDomain(addressDtos: List[AddressDTO]): User = ...
}
object UserDTO {
  def fromDomain(user: User): (UserDTO, List[AddressDTO]) =
    ...
}
val (userDTO, addressDTOs) = UserDTO.fromDomain(user)
for {
  _ <- sql"""INSERT INTO users ( ... )
            |VALUE ( ${userDTO.id}, ... )
            |ON CONFLICT (id) UPDATE ..."""
         .stripMargin.update.run
  addrInDB <- ... // fetch
  (toUpsert, toDelete) = compare(addressDTOs, addrInDB)
  _ <- sql"""INSERT INTO address ...
            |ON CONFLICT (user_id, position) UPDATE ..."""
         .stripMargin.update.run // using toUpsert
  _ <- sql"""DELETE FROM addresses WHERE ..."""
         .update.run // using toDelete
} yield ()
for {
  userDTO <-
    sql"""SELECT id, name, email, email_new, email_confirmed
         |FROM users
         |WHERE id = $userID"""
      .stripMargin.query[UserDTO].unique
  addressDTOs <-
    sql"""SELECT ... FROM addresses
         |WHERE user_id = $userID"""
      .stripMargin.query[AddressDTO].as[List]
} yield userDTO.toDomain(addressDTOs)
def setPrimaryAddress(user: User, primary: Address) = {
  val updated = user.modify(_.data.addresses).using {
    addresses =>
      primary :: addresses.filterNot(_ == primary)
  }
  for {
    _ <- persistUser(updated)
  } yield updated
}
def setAddressEndpoint(userID:     User.ID,
                       addressAPI: AddressAPI) = for {
  user <- getUser(userID)
  address = addressAPI.toDomain
  updated <- setPrimaryAddress(user, address)
} yield UserAPI.fromDomain(updated)

Integrations

Repetitive imports

import doobie._
import doobie.implicits._
import doobie.implicits.javatime._
import doobie.postgres._
import doobie.postgres.implicits._
import doobie.refined.implicits._
import CustomDoobieMeta._
object DoobieSupport
    extends doobie.Aliases // basic functionalities
    with doobie.hi.Modules
    with doobie.syntax.AllSyntax
    with ...
    with doobie.postgres.Instances // Postgres extensions
    with ...
    with doobie.refined.Instances // refined types
    with doobie.util.meta.MetaConstructors // Java Time
    with doobie.util.meta.TimeMetaInstances {
  // custom extensions and Meta instances
}
import DoobieSupport._
object TapirSupport
    extends sttp.tapir.Tapir
    with sttp.tapir.TapirAliases
    with sttp.tapir.codec.refined.TapirCodecRefined
    with sttp.tapir.json.jsoniter.TapirJsonJsoniter  {
  // custom Tapir extensions and instances
}
import TapirSupport._

Import by default

// default: import java.lang._, scala._, scala.Predef._

// since 2.13
scalacOptions +=
  "-Yimports:java.lang,scala,scala.Predef,cats.syntax.all"

Questons?

Thank you!