RizTech Academy logo
RizTech Academy
Capstone: The Trip SplitterLesson 2 of 540 min

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

  1. Write the three data classes exactly. Confirm Person("A") == Person("A") is true.
  2. Prove why Person must be a data class: make it an ordinary class, put it in a mapOf as a key, look one up, and watch it fail.
  3. Construct an Expense with a negative amount and confirm the require throws with its message.
  4. Construct one with an empty sharedBetween and confirm the second require fires.
  5. Check sharePaise for an expense of 30000 shared 3 ways (expect 10000). Then share it 4 ways and note the truncation (7500).
  6. Sketch the Split sealed class and one when over it, then decide honestly whether the capstone needs it. Argue your answer.
  7. Compare all three classes with your sketch from the brief. List every difference and who is right.

Official documentation

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