Free preview lesson ยท From the full paid path
Thinking in Objects, Not Boxes
17 min read
The Engineer Who Drew All of Swiggy and Froze at the Cart
I was on a panel in Bangalore in 2021. The candidate had six years of experience and a good HLD round behind him. He had drawn the whole Swiggy backend on the board without breaking a sweat. API gateway, an order service, a Kafka topic for events, Redis for the live cart, a sharded Postgres for orders, a CDN for menu images. It was a clean picture. Then my co-interviewer wiped half the board and said, "Forget the servers. Just write me the Cart class. What does it hold, what can I call on it?"
He picked up the marker and stopped. He wrote class Cart {, then a blank line, then // items, then nothing for almost a minute. Finally he wrote a Map<String, Integer> for item-to-quantity and a double total. When my colleague asked "who is allowed to change that total, and can it ever disagree with the items?", he had no answer. In the HLD picture the cart was one box with an arrow into it. On the class level, that same cart was suddenly a thing with rules, and he had never been asked to think about the rules.
He was not a weak engineer. He had just spent his whole career thinking at one altitude, where the unit is a service and the question is how data flows between services. Nobody had asked him to drop down a level, where the unit is an object and the question is what that object is responsible for. Those are two different skills, and the second one is what a low level design round tests.
Low level design is the skill of turning a feature into classes: deciding what the objects are, what each one is responsible for, and how they call each other. When you finish this page you will be able to look at a feature, name the handful of objects inside it, and say in one sentence what job each object owns. That is the mental move every later lesson in this module builds on.
Why Should You Care?
There are three reasons this matters, and none of them is "interviewers like OOP."
First, the LLD round is a different exam from the HLD round, and strong system-design people fail it all the time because they walk in with the wrong altitude. If you can talk Kafka but freeze at Cart, this page is the fix.
Second, this is what you actually do at work more hours than you draw architecture diagrams. You spend most of your week inside one service, adding a class, changing a method, deciding whether a new rule belongs on this object or that one. Getting that right is the difference between a codebase that stays soft and one that hardens into something nobody wants to touch.
Third, once you can see the objects, every design pattern later in this module stops being vocabulary to memorise and becomes an obvious answer to a problem you can now feel.
๐ข The Simple Version: Two Altitudes of the Same Feature
In plain words: high level design asks "which servers talk to which, and how does data move between them." Low level design asks "inside one of those servers, what are the objects and what is each one responsible for." Same feature, two zoom levels.
Think of building a housing society. The master plan shows where the towers go, where the water tank sits, how the sewage line runs to the main road. That is HLD. It does not tell the plumber which pipe connects to which tap inside flat 4B. The flat's own plumbing drawing does that. That is LLD. Both drawings are about the same building. They answer completely different questions, and a plumber who only has the master plan cannot fix your kitchen sink.
Here is the Swiggy cart at both altitudes.
Scroll sideways to read, or tap to zoom
At the HLD altitude the cart is one box. An arrow comes in from the app, an arrow goes out to the order service, and the box is labelled "cart service, backed by Redis." The questions are about the arrows: how many carts per second, what happens if Redis goes down, how big does one cart get.
At the LLD altitude that single box explodes into objects. There is a Cart that holds the things you added and knows its own total. There is a CartItem that is one dish in that cart, with a quantity. There is a MenuItem that lives on the restaurant's menu and carries a price. The Cart does not let anyone set the total directly, because the total is a fact computed from those cart items, not a number you are allowed to type in. Now the questions are about responsibilities: who computes the total, who is allowed to add an item, what stops the total from disagreeing with the items.
That last question is the whole game. In the HLD box, "total" was just a field. At the LLD altitude, "total" is a rule that some object has to own and protect. Deciding which object owns which rule is what designing classes actually is.
๐ก The Core Question: What Are the Things, and What Is Each One Responsible For?
Every LLD problem, without exception, reduces to two questions asked over and over:
- What are the things? The nouns that have their own state and their own behaviour. Cart, CartItem, MenuItem. Cart is the bag. CartItem is two masala dosas sitting in it. MenuItem is the dosa on the restaurant's menu, with a price. Not every noun in the prompt survives this step, and that is the skill.
- What is each thing responsible for? The one job that object owns and nobody else is allowed to do. The
Cartowns "keep the total honest."MenuItemowns "know my price."
When responsibilities are clear, the code almost writes itself, because there is exactly one obvious place for each new rule to go. When they are fuzzy, you get the class my candidate wrote: a Cart with a public total field that any caller can set, so the total and the items drift apart, and a bug report six weeks later says "customer charged 340 for a cart that shows 280."
Let me make the "keep the total honest" responsibility concrete.
final class MenuItem {
private final String id;
private final long pricePaise;
MenuItem(String id, long pricePaise) {
this.id = id;
this.pricePaise = pricePaise;
}
long pricePaise() { return pricePaise; }
}
final class CartItem {
private final MenuItem dish;
private int quantity;
CartItem(MenuItem dish, int quantity) {
this.dish = dish;
this.quantity = quantity;
}
MenuItem dish() { return dish; }
long subtotalPaise() { return dish.pricePaise() * quantity; }
void addMore(int n) { quantity += n; }
}
final class Cart {
private final List<CartItem> items = new ArrayList<>();
void add(MenuItem dish, int quantity) {
for (CartItem existing : items) {
if (existing.dish() == dish) {
existing.addMore(quantity);
return;
}
}
items.add(new CartItem(dish, quantity));
}
// v1: computed from the cart items, so it cannot disagree with them.
// A bill would snapshot price onto CartItem at add time instead.
long totalPaise() {
long sum = 0;
for (CartItem existing : items) {
sum += existing.subtotalPaise();
}
return sum;
}
}
Notice there is no setTotal. The total is not a thing you store and hope stays correct. It is a thing you compute from the items every time someone asks. That single decision, that the Cart owns the total and computes it rather than storing it, is a design choice, and it is invisible at the HLD altitude. The box diagram had a "total" and never asked who keeps it honest.
This is the shift. At HLD you move data between boxes. At LLD you assign responsibilities to objects and then protect them.
๐ก When Does Something Deserve Its Own Class?
The most common beginner mistake goes the other way from my frozen candidate. Once people learn "make classes," they make too many. They write a CartTotalCalculator class, a CartValidator class, a CartItemQuantity class, and now a simple cart is nine files. The opposite mistake, the god object that does everything, is just as bad. So the real skill is judgement about what earns a class.
Here is the test I use. Something deserves its own class when it has state and behaviour that belong together, and an identity that outlives a single method call. Run three checks.
Scroll sideways to read, or tap to zoom
- Is it just a value with no behaviour? Then it is a field or an enum, not a class. A cart's currency is
Currency.INR, an enum value, not aCurrencyclass with logic in it. A vehicle's kind in the parking lot lesson is an enum field,BIKEorCAR, not aBike extends Vehiclehierarchy. A truck is a vehicle in English, and that is-a is honest. The lot still uses an enum because no method on the vehicle actually differs by kind. Fit and price live outside it. If you find yourself writing a subclass whose only content is a different label, you wanted an enum. - Is it just an action with no state to protect? Then it is a method, not a class. "Calculate the total" is a method on
Cart, not aCartTotalCalculatorobject. It has no state of its own. It is a verb pretending to be a noun. The tell is a class name ending in "-er" or "-Manager" that holds no fields and has one method. - Does it have state that must stay consistent, and behaviour that protects that state? Then it earns a class.
Cartearns one: it holds cart items and protects the invariant that the total always matches them.CartItemearns one: it pairs a dish with a quantity and knows its own subtotal.
Let me make the confetti mistake concrete, because it is the one that looks like good engineering. Suppose someone extracts a CartTotalCalculator with a single calculate(Cart cart) method. It reads the cart's items, sums them, returns the total. It has no fields of its own. Now trace what that bought you. To compute a total you construct a CartTotalCalculator, pass it the Cart, and call calculate, when you could have called cart.totalPaise() directly. The calculator holds no state, so there is nothing for it to protect, and worse, the total's logic now lives outside the object that owns the cart items. That is a small step toward an anemic domain model: classes that only hold data, with the rules living in services. The rule of thumb: if a class name ends in "-er", "-Manager", or "-Calculator" and the class has no fields, it is almost always a method on some object that does have fields, wearing a class costume. Delete it and move the method home.
The reverse is equally worth seeing. A CartItem holds a MenuItem and a quantity, and it computes its own subtotalPaise(). Could you have used a bare Map<MenuItem, Integer> on the Cart and skipped the class? You could, and for a throwaway script you would. But the moment that row in the cart needs one more rule, a per-item discount, a "no more than ten of one dish" cap, a note to the kitchen, the map has nowhere to put it, and you end up with parallel maps that must stay in sync. CartItem earns its class the instant it has behaviour of its own to protect. That is the judgement in both directions: not too many, not too few, driven by whether there is state-plus-behaviour worth its own home.
The parking lot in a later lesson is a clean example of this judgement. A parking spot earns a class, because it has state (occupied or free) and behaviour that protects it (occupy throws if it is already taken). But the vehicle's kind does not earn a class, it is an enum, because a truck and a bike differ only in a label and a rule that lives elsewhere. Getting this line right is most of what separates a clean design from either a god object or a confetti of tiny useless classes.
๐ด Architect's Corner: Data Flow Thinking Leaks Into Class Design
The senior version of my candidate's freeze is subtler and more dangerous, because it compiles and ships. When you think in data flow for years, you start writing classes that are really just bags of data with the behaviour living somewhere else. That is the anemic domain model in full. The Cart becomes a struct with public getters and setters, and a separate CartService reaches in, reads the fields, does all the logic, and writes the fields back.
It looks organised. It is actually the same bug my candidate wrote, dressed in more files. The Cart no longer protects its own total, because the CartService sets it from outside. Any new piece of code that also touches carts can set the total to something inconsistent, and now the invariant is not protected in one place, it is hoped for in many. I have debugged a production incident where three different services each "knew" how to compute an order total, and they disagreed by the price of one delivery fee, and which answer you got depended on which code path last wrote the row.
The fix is not more services. It is putting the behaviour back on the object that owns the state. If the Cart owns the cart items, the Cart computes the total, and nobody else is allowed to. That is the whole idea of encapsulation, which the next lesson goes into properly. For now, the smell to remember is this: if your classes are all data and your services are all behaviour, you are still thinking in boxes, you have just drawn the boxes smaller.
v1 can compute the total from live menu prices. That is honest until a restaurant edits a price while a cart is still open, and the customer is charged a number they never saw. Then you snapshot pricePaise onto the CartItem at add time, or you store a total that only Cart.add and Cart.remove may write. Either way there is still no public setTotal. Compute-always is the teaching default. Snapshot is the production default for anything that becomes a bill.
The other senior trap is premature service-splitting at the class level. Because HLD rewards splitting things into separate deployable services, people import that instinct and split one cohesive object into five classes that can only ever be used together and always change together. Five classes that always change together are one class wearing a costume. Splitting has a cost, an argument passed, an interface to keep in sync, and you should only pay it when the parts genuinely vary independently.
How This Shows Up in India
Swiggy or Zomato cart. The cart is not a service you model, it is an object that owns "these items, this total, honestly." The surge fee and the coupon are separate objects that change the amount, not fields you scatter across a CartService. That separation is why a Diwali surge rule can ship without anyone touching the code that adds an item.
IRCTC ticket. At HLD, a booking is a box that writes to a database. At LLD, a Booking is an object with a state (requested, paid, confirmed, cancelled) and rules about which transitions are legal. "You cannot cancel an already-cancelled ticket" is an invariant the Booking object protects, not an if scattered across three services. When that rule lives in one object, the double-refund bug becomes impossible to write.
PhonePe or UPI payment. A payment at HLD is an arrow between a payer bank and a payee bank. At LLD, a Payment object owns the fact that its amount is money, stored as paise in a long, never a double, because rounding on money is a real bug that shows up when you sum a day of transactions. That decision is invisible at the box altitude and load-bearing at the object altitude.
The Decision Matrix
| What you are looking at | Altitude | The question it answers |
|---|---|---|
| Which services exist and how they talk | HLD | How does data flow, and what scales |
| What the objects inside one service are | LLD | What are the things, and what does each own |
| A value with no behaviour (currency, kind) | LLD detail | Make it a field or an enum, not a class |
| An action with no state (compute a total) | LLD detail | Make it a method on the object that owns the state |
| State plus behaviour that protects it | LLD detail | This earns its own class |
| Data in one class, all logic in a service | Smell | Anemic model. Put behaviour back on the object |
Common Mistakes
- "I designed the architecture, so I designed the system." You designed the boxes. The interviewer wiped the board and asked for the class. Those are two rounds, and the second one has its own skill.
- "Everything important should be a class." A
CartTotalCalculatorwith no fields and one method is a verb pretending to be a noun. It is a method onCart. Confetti classes are as much a smell as god objects. - "A truck is a Vehicle, so make a
Trucksubclass." The English is-a is real. The parking lot still uses a kind enum, because park and leave do not change by kind. Subclasses are for behaviour that genuinely differs, not for names. - "The
Cartholds the data, theCartServiceholds the logic." That is the anemic model. The total is now unprotected, because the service sets it from outside. Behaviour belongs on the object that owns the state. - "More classes means cleaner design." Five classes that always change together and can only be used together are one class you split for no reason. Splitting has a cost. Pay it only when the parts vary independently.
๐ง Key Takeaways
- HLD asks how data flows between services. LLD asks what the objects are and what each one is responsible for. Same feature, two altitudes.
- Every LLD problem is two questions: what are the things, and what is each thing responsible for. Get the responsibilities clear and the code writes itself.
- A value with no behaviour is a field or an enum. An action with no state is a method. Only state-plus-behaviour-that-protects-it earns a class.
- A derived fact belongs on the object that owns the inputs. A cart's total is computed from its items in v1, so it cannot disagree with them. A bill snapshots the price at add time. There is still no public
setTotal. - Data in the classes and logic in the services is the anemic model. It is data-flow thinking wearing smaller boxes. Put behaviour back on the object that owns the state.
Think About It
"You are asked to design the Order object for a food app. Someone suggests an OrderStatusManager class that reads the order's fields and decides what status it should be in. What would you push back on?" The status is state that the Order owns, and the rules about which status can follow which are behaviour that protects that state. A separate manager reaching in to set the status is the anemic model again. The transitions belong on the Order (or a state object it holds), so an illegal transition like reviving a cancelled order is impossible to write, not merely discouraged.
"A teammate models PetrolCar, DieselCar, and ElectricCar as three subclasses of Car for a ride-hailing app. When is that right, and when is it an enum?" It is an enum if the only difference is a label used for filtering or a rate looked up elsewhere. It earns subclasses only if the three genuinely behave differently in code, for example if an electric car's range calculation is real logic the object runs. Ask what method actually differs between the three. If none does, it is a fuelType enum.
"When would you deliberately keep a design as one bigger class instead of splitting it?" When the parts always change together and are never used apart. Splitting them into separate classes buys you nothing and costs you an interface to maintain and arguments to thread through. Cohesion, things that change together living together, is a feature, not a smell.
Further Reading
- Martin Fowler on the Anemic Domain Model. The precise description of the "all data, no behaviour" trap and why it is the opposite of object thinking.
- Refactoring Guru: Refactoring, extract and inline. The mechanical moves for splitting a class that grew too big, or inlining one that never earned its existence.
- Domain Modeling Made Functional, Scott Wlaschin (talk). A clear, language-agnostic take on making illegal states unrepresentable, which is where the "protect the invariant" idea leads.
Full quizzes, answers, progress, case studies and interview problems available in the paid path.