Designing the model with data and sealed classes
The model is three data classes, and every choice in them is a decision from earlier in the course.
This lesson builds Person, Expense, and Transfer, defending each line — because a capstone is
not about producing code, it is about being able to say why. Read it against your own sketch from
the brief.
Person — identity is the name
/** A person on the trip. Identity is the name, so a data class is right. */
data class Person(val name: String)
The smallest possible type, and every word earns its place. A data class because a person's
identity is their data — two Person("Kavita") values should be equal, which is exactly what data
classes give you (the data-classes module). This matters enormously here: Person is used as a map
key in the settlement (balances are keyed by person), and that only works because the data class
generates a correct equals/hashCode. An ordinary class would compare by identity, and
Person("Kavita") from one expense would not match Person("Kavita") from another — the settlement
would silently break. A val because a person's name does not change during the trip.
Expense — the heart of the model
data class Expense(
val description: String,
val paidBy: Person,
val amountPaise: Int,
val sharedBetween: List<Person>,
) {
init {
require(amountPaise > 0) { "amount must be positive, was $amountPaise" }
require(sharedBetween.isNotEmpty()) { "an expense must be shared between at least one person" }
}
/** Each participant's equal share, in paise. The payer's own share counts too. */
val sharePaise: Int get() = amountPaise / sharedBetween.size
}
Several course decisions are visible here:
amountPaise: Int — integer paise, never a Double. The brief's non-negotiable, and the
expensive-mistake rule from the database course in another language: money as floating point breaks
equality and drifts, so the "everyone is even" guarantee would fail by fractions of a paisa. The name
carries the unit — amountPaise, not amount — so nobody at a call site can mistake rupees for
paise. ₹9000.00 is 900000.
paidBy: Person and sharedBetween: List<Person> — the relationships modelled directly. One
person paid; a list of people shared it. A List (read-only) because order does not matter much but
we only ever read it, and read-only is the default (the collections module).
The init block validates. require(amountPaise > 0) { ... } rejects a nonsensical expense at
construction — an Expense with a negative or zero amount can never exist (the classes module). The
message names the bad value (was $amountPaise), so a failure explains itself (the errors module). An
object that validates itself at birth is one you never have to check again.
sharePaise is a computed property. It has a get() and no stored value, so it is always
amountPaise / sharedBetween.size — derived, never stored, never out of sync (the classes module,
and the generated-column idea from databases). Integer division truncates, which is a deliberate,
documented choice we return to.
Transfer — the output
/** A single transfer in the final settlement: [from] pays [to] this many paise. */
data class Transfer(val from: Person, val to: Person, val amountPaise: Int)
The result type: one person pays another an amount. A data class again, because a transfer is pure
value (two identical transfers are equal), and val throughout because a computed result should not
be mutated after the fact. The settlement produces a List<Transfer>, and that list is the answer
to "who owes whom".
Where a sealed class would fit — and why we do not force one
The design-patterns and classes modules made much of sealed classes, and you might expect one here.
Be honest about where it belongs: this model does not have a "one of a fixed set of kinds" shape, so a
sealed class would be forcing a pattern where the problem does not call for it — exactly the
over-engineering the design-patterns module warned against. A Person is a Person; an Expense is
an Expense. Three plain data classes fit the problem, so three data classes is the right design.
Where a sealed class would earn its place is if we extended the program — say, expenses split unequally (by percentage, by share, or equally). Then an expense's split rule is genuinely one of a fixed set of kinds:
sealed class Split {
object Equal : Split()
data class ByShares(val shares: Map<Person, Int>) : Split()
data class ByPercent(val percent: Map<Person, Int>) : Split()
}
and a when (split) over it would be exhaustive. We keep the capstone to equal splits, so we do not
add this — but recognising when a sealed class fits (a fixed set of kinds) and when it does not
(three distinct entities) is precisely the judgement the course was building. The right design uses
the feature the problem calls for, and no more.
The model, defended in one paragraph
Three data classes, because each type's identity is its data and each is used as a value (and Person
as a map key, which requires the data class's equals/hashCode). Money is integer paise with the
unit in the name, because floating-point money would break the settlement. The Expense validates
itself with require, so an invalid expense cannot exist. sharePaise is computed, so it can never
be stale. And we resist a sealed class because the problem has three distinct entities, not one fixed
set of kinds — using the feature the problem calls for, and no more. Every line traces to a lesson.
Check your work
Why Person is a data class. Its identity is its data; and it is used as a map key, which needs
the generated equals/hashCode — an ordinary class would break the settlement.
Why money is amountPaise: Int. Integer paise, never Double — floating-point money breaks
equality and drift, and the unit in the name prevents rupee/paise confusion.
What the init block does. Validates with require, so an Expense with a bad amount or no
participants can never be constructed; the message names the bad value.
Why sharePaise is a computed property. It is derived (amount / size), so it is always current
and never stored out of sync.
Why Transfer is a data class with vals. A transfer is pure value; a computed result should not
be mutated.
Why the model does not use a sealed class. The problem has three distinct entities, not one fixed set of kinds — forcing a sealed class would be over-engineering.
Where a sealed class would fit. If expenses could split by different rules (equal, by shares, by percent) — a genuine fixed set of kinds.
The design principle. Use the language feature the problem calls for, and no more.
Practice
- Write the three data classes exactly. Confirm
Person("A") == Person("A")is true. - Prove why
Personmust be a data class: make it an ordinaryclass, put it in amapOfas a key, look one up, and watch it fail. - Construct an
Expensewith a negative amount and confirm therequirethrows with its message. - Construct one with an empty
sharedBetweenand confirm the secondrequirefires. - Check
sharePaisefor an expense of30000shared 3 ways (expect10000). Then share it 4 ways and note the truncation (7500). - Sketch the
Splitsealed class and onewhenover it, then decide honestly whether the capstone needs it. Argue your answer. - Compare all three classes with your sketch from the brief. List every difference and who is right.
Official documentation
- Kotlin — Data classes — Equality,
hashCode, and whyPersonworks as a map key. - Kotlin — Classes: init blocks — Validation at construction.
- Kotlin — Properties: custom getters — The computed
sharePaise. - Kotlin — require — Rejecting invalid input.
Next: the settlement logic with collections and functions.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship