Designing the Expense Object for a Splitwise-Style Tracker

How to turn add, edit, settle, and group expense into an actual object model, and the specific judgment call that decides whether Group should store its own list of expenses.

September 14, 20268 min read2 / 5

The last post stopped right before the class diagram, on purpose. The four requirements were locked in first: add, edit, settle, and group expenses, with simplifying debts as the one feature that actually tests object design.

Now it is time to build the objects that make those four requirements real.

Two Ways to Start a Design

There are two honest ways to approach any design problem, high level or low level.

The behavior-first way starts with what the user needs to do. You list the actions (add an expense, settle a group), then figure out which services handle each one, then let the database shape fall out of that.

The table-first way starts with what data needs to exist. You decide what to store and how it relates, then the actions become simple: read some rows, write some rows.

For a high-level system, behavior-first usually wins. You are choosing between services, queues, and databases, and the user-facing behavior drives those choices.

For low-level design, table-first is the easier path. A machine coding round is really asking you to write the actual classes and fields that will become real code in an hour. Thinking in tables forces you to be specific immediately: what fields does this object have, what type is each one, how does it connect to the next object. That specificity is exactly what gets graded.

A Restaurant Bill Makes the Core Idea Concrete

Picture six friends at dinner: James, Denver, Neal, Priya, Amit, and Riya. Only two of them, James and Denver, actually pay the bill.

Scenario one: everyone splits evenly.

James and Denver each pay ₹600, for a total bill of ₹1,200 across six people. Split evenly, everyone owes ₹200 each.

  • James paid ₹600, owes ₹200, so his balance is +400.
  • Denver paid ₹600, owes ₹200, so his balance is +400.
  • Neal, Priya, Amit, and Riya each paid ₹0, owe ₹200, so each of their balances is -200.

Scenario two: the split isn't even.

Now say Denver ordered a lot more than everyone else. His actual share of the bill is ₹400, not ₹200. Neal and Amit barely ate, so their share drops to ₹100 each.

  • Denver paid ₹600, owes ₹400, so his balance becomes +200.
  • Neal and Amit each paid ₹0, owe ₹100, so each of their balances is -100.
  • James and the remaining two friends keep whatever balance their own paid-minus-owed works out to.

The two scenarios use the same six people and the same total bill, but produce completely different balances. That is the whole reason "how much did you pay" and "how much do you owe" have to be tracked as two separate numbers per person, not folded into one.

Expense: A Map, Not Two Lists

Every person in that bill needs two numbers attached to them: what they paid, and what they owe. Subtract one from the other and you get their balance for that expense.

It is tempting to store this as two separate lists, one of payers and one of owers. Don't. A person's paid amount and owed amount describe the same fact about the same person in the same expense. Splitting them into two lists just means writing extra code later to match a person back up with both their numbers.

The right shape is one map: User → Balance. Every user involved in the expense is a key, and their balance (paid minus owed, collapsed into a single signed number) is the value. One coupled structure, not two lists that can drift out of sync.

On top of that core map, an expense carries its own metadata:

  • title, so you know why the expense exists later
  • timestamp, for ordering and history
  • image, usually a photo of the receipt
  • comments, for anyone who wants to flag a mistake without editing the record

Two Small Objects Backing the Map

The map's key and value are not primitives. Both deserve to be their own objects.

User needs an id, a profile image, and a short bio. Nothing complicated, this is close to what any app's user object looks like.

Balance needs an amount, plus something the amount alone doesn't tell you: which currency it's in. A balance of 200 means nothing on its own. 200 rupees and 200 dollars are not the same fact, even though the number looks identical.

With User and Balance both defined, the expense's core map, Map<User, Balance>, is fully specified. An "add expense" API call just needs to fill that map plus the metadata fields, and the object is complete.

Editing Changes the Record, Settling Flags It

Edit expense works off the expense's own id. Once you have that id, you can change the balance map, the title, or the image, whatever was wrong the first time.

Settle expense is different. It doesn't rewrite anything.

An expense gets a simple boolean flag, something like active. When an expense is settled, that flag flips, and the balance calculation logic skips any expense where the flag says settled.

The expense record itself never disappears. Settling is really just an edit of that one flag, the same way editing a title is an edit of that field. Nothing about the person's payment history gets destroyed just because the debt closed out.

Group: id, Description, Image, Title, Users

A group looks a lot like a user object at first glance. It needs an id, a title, and an image, the same three basics.

Instead of a bio, a group gets a description (something like "our Goa trip"), and instead of one person, it holds a list of users who belong to it.

That covers everything a group needs to exist. What it deliberately does not hold is more interesting.

The Call That Matters: Does Group Store Its Own Expenses?

Here is the actual design decision worth sitting with: should a Group keep a list of its own expenses?

The instinct says yes. A group has expenses, so why not store them right there.

Don't. Two reasons, and both matter.

First, a group that has been active for months could easily accumulate hundreds of expenses. Stuffing that entire list directly onto the Group object bloats it every time you just want the group's basic info, its title, its members, its image.

Second, and more fundamentally: a group's identity was never defined by its expenses. Two groups with the exact same members, same title, same image are still the same kind of object whether one has three expenses and the other has three hundred. The expense list isn't part of what makes a Group a Group.

An Expense object holding a groupId back-reference, versus a Group object that deliberately does not store its own list of expenses ExpandAn Expense object holding a groupId back-reference, versus a Group object that deliberately does not store its own list of expenses

The fix flips the direction of the pointer. Instead of Group holding a list of Expense objects, each Expense carries a groupId field pointing back at the group it belongs to. Adding an expense to a group is just setting that one field, nothing on the Group object changes at all.

To find every expense in a group, you query expenses by groupId instead of reading a list off the group.

This isn't fully normalized data, strictly speaking. In a purely abstract sense, an expense already belongs to a group the moment it sits inside that group's expense list, so storing the groupId again is technically redundant information. But in a real system, that redundancy is worth paying for. Looking up "which group does this expense belong to" without it would mean scanning every group's expense list to find the one that contains this expense, which is a far more expensive question to answer than reading one field. The BookMyShow LLD series hits this exact same tradeoff from the other direction, one relationship, represented on both sides on purpose.

Getting a Group's Final Balances

Once expenses exist with their balance maps and their groupId, computing a group's overall balances is just addition.

getGroupBalances(groupId) pulls every expense tied to that group, then sums each user's balance across all of them.

Say a group has three expenses, tracked only for James, Denver, and Neal:

JamesDenverNeal
Expense 1+10+20-30
Expense 2-50+10+40
Expense 3+900-90
Total+50+30-80

James's three balances add up to 50. Denver's add up to 30. Neal's add up to -80, and you can check the whole group nets to zero: 50 + 30 - 80 = 0.

Every individual expense also has to net to zero on its own. Look back at Expense 1: 10 + 20 - 30 is 0. Same for Expense 2 and Expense 3. If an expense's balances don't sum to zero, someone's share was calculated wrong, and that's cheap enough to catch as a validation check the moment an expense gets added, not something you discover later during settlement.

getGroupBalances returns exactly this final row, a map of user to their net balance for the group. That's the number each person actually owes or is owed once every unsettled expense gets added up, since a settled one is already flagged out of the calculation, the same flag from the edit/settle section earlier.

What it does not tell you is who should pay who. James is owed 50 and Neal owes 80, but turning three net numbers into an actual minimal set of payments is a separate problem, the same debt-simplification puzzle the previous post worked through with smaller chains. That algorithm, building the actual payment graph from a set of final balances, is where the next post picks up.