Three Services, One Call Flow, and the Rules for Defining Objects Well

How GroupService, ExpenseService, and UserService actually talk to each other, plus the three rules for defining objects that decide most of an LLD interview.

September 14, 20265 min read5 / 5

The last post said this one covers how the algorithm, the cache, and the immutable maps actually fit together as classes. Here is that architecture.

This tracker splits into three services: GroupService, ExpenseService, and UserService. Each one owns its own storage underneath it. You are not expected to actually build that storage, a plain in-memory map or a mocked lookup is enough. What matters is which service owns which data, and which service calls which other service.

Tracing One Real Call

The clearest way to see the architecture is to trace a single call all the way through: getGroupPaymentGraph(groupId, userId).

A caller does not hit ExpenseService directly for this. It calls GroupService, and it has to pass two things.

  1. groupId, so GroupService knows which group's payments to look at.
  2. userId, so GroupService can check the caller actually belongs to that group.

That second parameter is doing real work. If I try to pull the payment graph for a group I am not in, GroupService rejects the call before touching any data. Membership is checked first, against GroupService's own storage, not ExpenseService's.

Once I am confirmed as a member, the call moves forward in three more steps.

  1. GroupService calls ExpenseService.getGroupExpenses(groupId), which pulls every expense tied to that group from its own storage.
  2. GroupService sums those expenses into one balance per user. Every expense contributes its own little slice, and they all add up into a single running total per person.
  3. GroupService hands those summed balances to ExpenseService.getPaymentGraph(), the same greedy settlement algorithm from an earlier post, and returns whatever comes back to the caller.

GroupService checks membership, pulls every expense from ExpenseService, sums them into balances, then calls ExpenseService's payment graph algorithm on those balances before returning the result to the caller ExpandGroupService checks membership, pulls every expense from ExpenseService, sums them into balances, then calls ExpenseService's payment graph algorithm on those balances before returning the result to the caller

That same getPaymentGraph step works on one expense too, not just a whole group's worth. Feed it a single expense's balances instead of a group's summed balances, and it settles just that one expense the same way. The algorithm does not know or care whether the numbers it received came from one expense or fifty.

The Real Interview Skill: Defining Objects Well

Everything above is architecture. But the part of an LLD interview that actually gets graded the hardest is smaller than architecture: how well you define your objects in the first place.

A clearly named, clearly defined object makes the rest of the problem easier to reason about. A messy one drags every method that touches it down with it. This one skill decides more of the interview than the algorithm does.

Three rules make the difference.

1. Name things clearly

This sounds too simple to matter, but it is the single most heavily weighted part of the interview. ExpenseService.getGroupExpenses tells you exactly what it returns before you read a line of its body. A name like ExpenseService.process tells you nothing.

A clear name is a promise about behavior. Every method and class in this tracker, getGroupPaymentGraph, getGroupExpenses, getPaymentGraph, was picked so an interviewer could read the call flow above and follow it without asking what anything does.

2. Use composition, not one giant object

When an object starts collecting fields, the fix is not more fields. It is breaking it into smaller, well-named objects and composing them together.

The Expense object from an earlier post is the concrete example already sitting in this design. It is not one flat object with a dozen loose fields. It is a User, a Balance, and metadata, composed together into one Expense. Each piece stays simple. The composition is what carries the complexity, not any single field list.

3. Be careful with inheritance

This is the one that trips people up in an interview, and it is worth slowing down on.

In an LLD interview, inheritance should come from interfaces, not from concrete classes. An interface is just a contract, a list of method names a class promises to implement, with no behavior of its own attached. Inheriting from a real class is different: you pick up whatever that class already does, whether it fits your new class or not.

Picture a class with five methods. A child class extending it gets all five, automatically, even if two of them make no sense for what that child actually is. That mismatch is exactly the kind of fragile design an interviewer is watching for.

This is not a new idea invented for this post. It is the exact lesson the SOLID series on this site spent several posts building up to, when a bird's flying behavior went from a shared concrete class to a swappable FlyingBehavior interface. The post that first replaced inheritance with an interface is solving the identical problem this tracker's objects are solving: a class should depend on a contract, not on another class's baggage.

If that series already clicked for you, this rule is not new information. It is the same judgment call, showing up again in a real interview question. Treat class inheritance as rare and risky, and reach for an interface instead whenever a class just needs to promise it can do something, not inherit how something else already does it.

The Roadmap, Start to Finish

Zoom out, and this whole design process has followed one order.

  1. Define the objects. Done, across the earlier posts in this chapter: Expense, Group, User, and now the three services and how they call each other.
  2. Write the algorithm. Done too, the greedy settlement algorithm that turns balances into the fewest payments.
  3. Write test cases. This is what is left, and it happens alongside real code, not before it.

That order is not arbitrary. You cannot write a meaningful test case against an object that is not defined yet, and you cannot test an algorithm you have not written. Objects first, algorithm second, tests last, in that order, every time.

Every conceptual piece of this tracker is now on the table. The next thing this chapter needs is the actual TypeScript, the services, the objects, and the tests, written as real, runnable code.