Kotlin / Language foundations and explicit state / Lesson 5
Model state with classes and data classes
Use constructors, init validation, properties, equality, and copy while respecting encapsulation and shallow state.
On this page
Lesson goals
- Explain which constructor properties participate in data-class equality and copying.
- Keep invalid state out of a model without assuming val freezes nested objects.
Put required state in the primary constructor
A class groups state and behavior. Primary-constructor parameters become properties only when declared with val or var; an ordinary parameter can still be used for initialization. An init block runs during construction and can check invariants. Prefer a model that is valid immediately after construction over one requiring callers to remember several setup steps. Initialization order matters when properties depend on earlier state. Avoid calling overridable behavior from construction because a subclass may not yet have initialized the data that the overridden member expects.
Use controlled state changes to preserve the rule
The example class requires a nonnegative starting capacity and exposes remaining as publicly readable with a private setter. Its reserve operation returns false when no seat remains instead of allowing negative state. This models one local owner, not thread-safe shared allocation or durable booking. Two concurrent callers would require additional synchronization and storage design. Encapsulation is useful because callers cannot bypass the decrement rule through ordinary property assignment; it does not create guarantees outside the process or automatically protect nested mutable objects.
class SeatCounter(initial: Int) {
var remaining: Int = initial
private set
init { require(initial >= 0) { "negative capacity" } }
fun reserve(): Boolean {
if (remaining == 0) return false
remaining -= 1
return true
}
}
fun main() {
val counter = SeatCounter(1)
println(counter.reserve())
println(counter.reserve())
println(counter.remaining)
}
true
false
0
Expected output is true, false, 0. The class prevents negative capacity through its declared public operations in this single-threaded example. It was not compiled or concurrency-tested here.
Use a data class for value-oriented records
A data class generates useful operations such as equals, hashCode, toString, component functions, and copy based on properties in its primary constructor. Properties declared only in the body do not participate in those generated value operations. That distinction can surprise a cache key or comparison if important identity was placed outside the constructor. Use == for structural equality according to equals and === for reference identity. Arrays have their own content-comparison considerations; do not assume every container’s equality recursively compares all contents in the same way.
Copying a data class is shallow
copy constructs another instance while defaulting unchanged constructor arguments to the current property values. If a property contains a mutable list, both instances can still point to the same list. Prefer value-like properties when a data class represents a stable record. The sample uses strings and integers so copying the seat count has straightforward semantics. A copy must also satisfy constructor validation when present. Copy is not a database update, a deep clone, or a proof that an object’s external resources have been safely duplicated.
data class Reservation(val workshopId: String, val seats: Int)
fun main() {
val first = Reservation("ws-6", 1)
val equalValue = Reservation("ws-6", 1)
val changed = first.copy(seats = 2)
println(first == equalValue)
println(first === equalValue)
println(changed.seats)
println(first.seats)
}
true
false
2
1
Expected output is true, false, 2, 1. Distinct instances can be equal by value, and changing the copied seat count leaves the original integer property unchanged. No compiler result is claimed.
Choose inheritance and mutability deliberately
Kotlin classes and members are final by default unless opened or defined under another applicable rule. This encourages explicit extension contracts instead of accidental overrides. An interface can express behavior without requiring one shared stateful superclass. Prefer composition when one object uses another capability rather than truly substituting for it. Public mutable properties become part of the model’s behavior and can undermine invariants if unrestricted. Begin with a small constructor and narrow operations, then add extension points only when a real use case defines what overriding is allowed to change.
Common errors and fixes
Two data records compare equal despite different body properties.
- Why it happens
- Generated equality considers primary-constructor properties.
- How to fix it
- Move value-defining state into the constructor or define intentional equality.
- Prevent it next time
- Test equality using every domain-significant field.
Editing a copied list also changes the original record.
- Why it happens
- copy retained a shared mutable reference.
- How to fix it
- Use immutable elements or a suitable explicit copy policy.
- Prevent it next time
- Document shallow-copy semantics at the model boundary.
A public setter makes capacity negative.
- Why it happens
- State mutation bypasses the invariant-preserving operation.
- How to fix it
- Restrict assignment and expose validated operations.
- Prevent it next time
- Check that every public state transition preserves the invariant.
Check your understanding
1. Why is SeatCounter not enough for a real multi-user reservation service?
Show worked solution
Its state is in one object’s memory and its check and decrement are not a durable cross-request transaction. It lacks request identity, persistence, concurrency control, and recovery. The example teaches encapsulation only. A service must protect the authoritative capacity invariant with an implementation appropriate to its storage and execution model.
2. When should a primary-constructor data property use val?
Show worked solution
Use val when the property should not be reassigned after construction. This supports value-like records and predictable copies, but a referenced mutable object can still change internally. Inspect the entire reachable state before claiming deep immutability or safe sharing between concurrent tasks.
Key takeaways
- Construct valid objects and control their state transitions.
- Data-class value operations follow constructor properties and shallow references.