Scala/Play, Part 2: Classes, Case Classes, and What Equality Means
Scala/Play · Part 2
In Part 1, I took a small premium calculation from Java-style declarations to Scala values, expressions and a method. Now I want to put the policy's data together, then understand what it means for two policies to be equal.
I am keeping the same fictional rule: a base premium of 600 whole euros, reduced by 50 after at least three claim-free years. Keeping the numbers familiar lets me focus on the language. This is a learning example, not a production insurance model.

A constructor beside the class name
Here is our ordinary Scala 2 class:
class Policy(
val number: String,
val basePremium: Int,
val claimFreeYears: Int
) {
def annualPremium: Int = {
if (claimFreeYears >= 3) {
val discount: Int = 50
basePremium - discount
} else {
basePremium
}
}
}
The parameters beside Policy define its primary constructor. Each val also gives the instance a readable property without a setter. From Java, I can think of the familiar combination of a constructor, private final fields and getters, while remembering that Scala uses property syntax:
val policy = new Policy("POL-001", 600, 3)
println(policy.number) // POL-001
println(policy.annualPremium) // 550
policy.number accesses a generated getter. In an ordinary class, leaving val or var off a constructor parameter does not automatically expose a public property. The Scala classes guide explains these constructor and member distinctions.
annualPremium is still a method. It uses the values stored on that particular object, and its body runs on each access. We declared it without a parameter list, so we call it without parentheses. A val would instead store its initialized result. Our additional policy with a base of 500 and three claim-free years produced 450 when we ran the lab.
Equal values, separate instances
Now construct two ordinary policies with identical inputs:
val ordinaryA = new Policy("POL-001", 600, 3)
val ordinaryB = new Policy("POL-001", 600, 3)
println(ordinaryA == ordinaryB) // false
The Java instinct needs a small adjustment here. For these object references, Scala's == performs a null-safe equality check using equals. Our ordinary class has not overridden that method, so its inherited equality uses object identity. Identical fields do not change that default.
| Expression | Question for object references |
|---|---|
| Java: a == b | Are these references to the same object? |
| Scala: a == b | Are these equal according to equals, with null handled safely? |
| Scala: a eq b | Are these references to the same object? |
I do not want to write equality boilerplate merely to describe a small data snapshot. That is where a case class helps.
Describe the data with a case class
case class PolicySnapshot(
number: String,
basePremium: Int,
claimFreeYears: Int
)
val original = PolicySnapshot("POL-001", 600, 3)
val sameValues = PolicySnapshot("POL-001", 600, 3)
println(original == sameValues) // true
println(original eq sameValues) // false
The compiler supplies value-based equals, a matching hashCode, and a readable toString. The parameters in this list become val properties by default. The two objects above are distinct instances, but all three constructor values match.
For my Java background, a record is a useful comparison for the data-carrier role, though the two language features are not identical. I kept Policy and PolicySnapshot side by side in the lab so their equality behaviour is directly observable.

Where did new go?
PolicySnapshot("POL-001", 600, 3) calls the generated companion object's apply method. Writing PolicySnapshot.apply("POL-001", 600, 3) makes that call explicit. It constructs an instance; omitting new does not imply caching or reuse. Explicit construction with new PolicySnapshot(...) also works. Companion objects deserve their own next step.
Copy with one changed value
Suppose the policy reaches four claim-free years:
val renewed = original.copy(claimFreeYears = 4)
println(original)
// PolicySnapshot(POL-001,600,3)
println(renewed)
// PolicySnapshot(POL-001,600,4)
println(original == renewed) // false
claimFreeYears = 4 is a named argument to copy, not an assignment to original. The generated method constructs another instance, retaining the current values for arguments I omit. The original remains unchanged. Scala's case-class guide covers this generated behaviour.
For this example, the operation is equivalent to constructing new PolicySnapshot(original.number, original.basePremium, 4). The printed values make the effect visible without a debugger.

Two boundaries worth keeping clear
First, copy is shallow. A field referencing a mutable object would still reference that same object unless I supplied a replacement. Default val properties prevent reassignment; they do not recursively freeze everything reachable through them.
Second, generated equality is not a business identity rule. Our two snapshots with the same policy number but different claim-free years compare unequal. If the business asks whether they concern the same policy, comparing original.number == renewed.number answers that narrower question.
Run the comparison
The existing Scala 2.13.18 lab now has Policy.scala, PolicySnapshot.scala and CaseClassLesson.scala. Its existing entry point calls the new lesson. From the directory containing build.sbt, run sbt run and find:
--- Case classes ---
Ordinary class, equal values: false
Case class, equal values: true
Case class, same instance: false
Readable property: POL-001
Original: PolicySnapshot(POL-001,600,3)
Renewed: PolicySnapshot(POL-001,600,4)
Original equals renewed: false
This is the output we verified. My next step is to unpack companion objects and apply before introducing pattern matching. Play can wait until these small pieces feel familiar.