Case classes are data models, not your whole domain
What Scala case classes generate, why value semantics are useful, and how to keep validation, invariants and persistence concerns under control.
A case class gives a value a useful identity
A case class generates value-based equals and hashCode, a readable toString, accessors for constructor parameters, a copy method and companion apply and unapply methods. Those defaults make immutable records concise and easy to use in collections, tests and pattern matches.
Value equality is the central feature. Two separately constructed values with the same fields compare as equal, which suits commands, identifiers, configuration and domain snapshots. That is different from an entity whose identity remains the same while its recorded attributes change.
Case classes are not deeply immutable by magic. A val prevents reassignment of the field, but a mutable collection or object stored inside the field can still change. Prefer immutable field types when the value is intended to be stable.
final case class Account(id: AccountId, name: String, balance: BigDecimal)
val original = Account(AccountId("a-17"), "Ada", BigDecimal(20))
val updated = original.copy(balance = BigDecimal(35))
original.balance // 20
updated.balance // 35
original == Account(AccountId("a-17"), "Ada", BigDecimal(20)) // truePut invalid primitive values behind constructors
A case class with String and BigDecimal fields may allow empty names, negative balances or identifiers in the wrong format. Checking those rules only in a controller leaves other construction paths unprotected.
A small value type can own parsing and validation. Its companion returns Either rather than throwing for expected invalid input. Once constructed, application code can rely on the invariant without repeating checks at every call site.
Not every String needs a wrapper. Add a domain type when it prevents a real category of mistake, centralises a meaningful rule or stops two values with the same runtime representation from being interchanged.
final case class AccountId private (value: String) extends AnyVal
object AccountId {
private val Valid = "[a-z0-9-]{3,40}".r
def from(raw: String): Either[String, AccountId] =
raw match {
case Valid() => Right(AccountId(raw))
case _ => Left("invalid account id")
}
}
AccountId.from("a-17") // Right(AccountId("a-17"))
AccountId.from("") // Left("invalid account id")copy is convenient because it is unrestricted
copy treats a case class as a product of its fields. That is ideal for ordinary immutable data, but it does not express which business transitions are permitted. account.copy(status = Closed) can bypass rules about outstanding balances if status is publicly replaceable.
Model important transitions as methods or functions that validate the current state and return the next state. The case class can remain the representation while the transition becomes the public operation. For stricter control, keep constructors and representations inside a module and expose only smart constructors and behaviours.
This is a judgement call. A request DTO benefits from unrestricted copy. An aggregate with consequential state changes may need a smaller public surface.
sealed trait AccountStatus
object AccountStatus {
case object Open extends AccountStatus
case object Closed extends AccountStatus
}
final case class Account(id: AccountId, balance: BigDecimal, status: AccountStatus) {
def close: Either[CloseError, Account] =
if (balance == 0) Right(copy(status = AccountStatus.Closed))
else Left(CloseError.OutstandingBalance(balance))
}Pattern matching can couple callers to representation
The generated unapply makes case classes pleasant to deconstruct. It also means callers can become coupled to constructor order and field shape. Adding a field may require edits across matches that only needed one property.
Prefer named accessors or behaviour methods when the caller is not genuinely interpreting the whole product. Pattern matching is strongest when used with a closed set of alternatives, such as a sealed trait whose cases carry different data.
At external boundaries, derived serializers can create similar coupling. A convenient Json.format or database codec is still a representation decision. Protect important payloads and persisted formats with tests before changing the case class.
sealed trait PaymentResult
object PaymentResult {
final case class Accepted(receipt: Receipt) extends PaymentResult
final case class Declined(reason: DeclineReason) extends PaymentResult
}
def message(result: PaymentResult): String = result match {
case PaymentResult.Accepted(receipt) => s"accepted: ${receipt.id}"
case PaymentResult.Declined(reason) => s"declined: ${reason.message}"
}Separate domain, transport and storage where they diverge
Reusing one case class everywhere is reasonable while the shapes and change cycles are genuinely the same. Separate it when JSON compatibility, database migrations or domain invariants begin pulling in different directions.
A transport model can reflect an optional legacy field. A persistence model can include schema version and migration metadata. The domain model can then state what current application logic requires. Translation code is not waste when it prevents accidental coupling between those contracts.
The aim is not to maximise the number of models. It is to make each important representation explicit enough that a local refactor cannot silently alter an API or stored document.