case class Value(a: Int, b: String)
Mateusz Kubuszok
breaking things in Scala for 5+ years
breaking things for money for 8 years
breaking things for fun for 16(?) years
blog at Kubuszok.com
niche Things you need to know about JVM (that matter in Scala) ebook
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
esWhat 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")
}
we want to represent a value/record
all properties are value types
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])
default Eq
should match .equals
do not use Show
for any business logic, only debugging (same for .toString
)
case class UserAddress(
id: UUID,
userID: UUID,
addressLine1: String,
addressLine2: Option[String],
city: String,
zipCode: String
)
// 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)
case class DataRecord(
name1: String,
name2: String,
yetAnotherName: String,
// ... 20-30 fields more
metricX: Int,
metricY: Int
)
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
userAddress1 == userAddress2
userAddress1.id == userAddress2.id
userAddress1.data.userID == userAddress2.data.userID
userAddress1.data.address == userAddress2.data.address
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
ssealed 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 =>
}
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")
}
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
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
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)
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._
// default: import java.lang._, scala._, scala.Predef._
// since 2.13
scalacOptions +=
"-Yimports:java.lang,scala,scala.Predef,cats.syntax.all"