Course outline

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.

About 4 min readContent reviewed
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.

kotlin A locally encapsulated state transition
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)
}
Expected output
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.

kotlin Compare value equality, identity, and a changed copy
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)
}
Expected output
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.

Official references