DEV Community

Cover image for Episode 4: Low-Level Design
surajrkhonde
surajrkhonde

Posted on

Episode 4: Low-Level Design

This series follows a fictional conversation between an experienced engineer and his nephew. Every episode explores one stage of how software moves from an idea to production.


👦 Nephew: Uncle, I have the boxes. Frontend, Wishlist API, Wishlist Service, Database. I even know which walls can move later. Surely now I can code?

👨‍🦳 Uncle: Look at one box only — Wishlist Service. Forget the API in front of it, forget the database behind it. Just that one box. Tell me what's inside it.

👦 Nephew: ...it "handles wishlist stuff"?

👨‍🦳 Uncle: That's not an answer, that's a label. HLD told you the box exists. It never told you what's inside. That's Low-Level Design — you stop looking at the city map, and you walk into one building and decide what each room actually does.

👦 Nephew: Isn't that just... writing the code?

👨‍🦳 Uncle: Not yet. You still don't open VS Code. You decide, on paper, what functions this box needs, what each one takes in and gives back, where the rules live, and how it behaves when something goes wrong. Get this right, and writing the code becomes much more straightforward. Get it wrong, and no amount of clean syntax saves you.


What Functions Actually Live Here

👨‍🦳 Uncle: Start with the obvious question — what can you actually do to a wishlist?

WishlistService

  addItem()
  removeItem()
  listItems()
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: That seems complete. Add, remove, list.

👨‍🦳 Uncle: Look again at Episode 1. What did we discover about "add"?

👦 Nephew: ...you can't add the same product twice. So there's a duplicate check somewhere.

👨‍🦳 Uncle: Right — but is that its own function, or is it hidden inside addItem()?

WishlistService

  addItem(userId, productId)
    → validateInput(productId)
    → checkDuplicate(userId, productId)
    → save(userId, productId)

  removeItem(userId, productId)

  listItems(userId)
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: Why pull checkDuplicate out separately instead of just writing the check as a line inside addItem?

👨‍🦳 Uncle: Because you'll need that exact check again. Suppose next quarter someone builds a "bulk import wishlist from another app" feature. If duplicate-checking is buried as a few lines inside addItem, you copy-paste it. If it's its own function, you call it. Copy-pasted logic drifts apart over time — someone fixes a bug in one copy and forgets the other exists. You saw that exact mistake in Episode 2.


Deciding the Shape of Each Function

👦 Nephew: Okay, so I know the function names. What else is there to decide?

👨‍🦳 Uncle: Everything about its shape. What goes in, what comes out, and what happens when things go wrong. Watch — I'll design addItem properly.

addItem(userId: string, productId: string): WishlistItem

Throws:
  ProductNotFoundError   — if productId doesn't exist
  DuplicateItemError     — if this user already has this product
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: Why throw errors instead of just returning { success: false, reason: "..." }?

👨‍🦳 Uncle: Genuine trade-off, not a rule. Returning a result object forces every caller to remember to check it — easy to forget, and the mistake compiles fine. Throwing forces the caller to deal with failure, or the error surfaces loudly instead of silently. For something like "duplicate item," which the caller almost always needs to react to differently than success, throwing a specific, named error is usually clearer than a generic boolean.

👦 Nephew: Why two different error types instead of one generic WishlistError?

👨‍🦳 Uncle: Because the API layer above you needs to react differently. "Product doesn't exist" is a 404. "Already in wishlist" is a 409. If both throw the same generic error, whoever writes the API layer has to inspect a message string to figure out which happened — fragile, and it breaks the moment someone rewords the message.


Where Validation Lives, and Where Business Rules Live

👦 Nephew: Is checking "does productId look like a valid ID" the same kind of thing as checking "does this user already own this item"?

👨‍🦳 Uncle: No — and mixing these up is one of the most common LLD mistakes. One is input validation — is the shape of what I received even sane? The other is a business rule — given valid input, does our specific policy allow this action? Keep them apart.

Input validation:
  Is productId a non-empty string in the expected format?
  → This has nothing to do with "wishlist" as a concept.
    It would be exactly the same check in any feature that takes a productId.

Business rule:
  Does this user already have this product wishlisted?
  → This is specific to how Wishlist behaves.
    A different feature might have a completely different rule here.
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: Why does the split actually matter, in practice?

👨‍🦳 Uncle: Because it tells you where the code should live. Input validation is often generic enough to share across features — a shared validator, or middleware. Business rules belong inside the service, close to the domain they govern. If you bury a business rule inside a generic validator, the next engineer won't think to look there when the rule needs to change.


How Errors Flow Upward

👦 Nephew: So when checkDuplicate throws DuplicateItemError inside addItem — where does that error actually go?

👨‍🦳 Uncle: Trace it with me.

Wishlist API
   ↓ calls
addItem() in Wishlist Service
   ↓ calls
checkDuplicate()
   ↓ throws DuplicateItemError
      ↑
   addItem() does NOT catch it — lets it bubble up
      ↑
Wishlist API catches it, maps it to HTTP 409
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: Why does addItem let it pass through instead of catching it there?

👨‍🦳 Uncle: Because addItem doesn't know what an HTTP status code is, and it shouldn't. That knowledge belongs to the API layer, which is the only box that talks to the outside world. If Wishlist Service starts deciding HTTP status codes, you've coupled your business logic to the transport layer — and the moment you reuse this service somewhere that isn't HTTP, that decision is dead weight sitting in the wrong box.

👦 Nephew: Same idea as Episode 3 — a box should only know its own job.

👨‍🦳 Uncle: Exactly the same principle, one layer deeper.


One Class, or Several?

👦 Nephew: Right now everything — the functions, the duplicate check, the save — is sitting inside one WishlistService. Should it be?

👨‍🦳 Uncle: Ask what each piece actually depends on. checkDuplicate and save both need to talk to the database. addItem and removeItem need to enforce rules, but they don't need to know how data gets stored — only that it does. That's usually the seam.

WishlistService            WishlistRepository
  addItem()        ──►       save()
  removeItem()      ──►      delete()
  listItems()       ──►      findByUser()
                              existsForUser()
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: So Service holds the rules, Repository holds the database calls?

👨‍🦳 Uncle: That's the split, and here's why it's worth the extra file: if Flipkart ever moves this table from one database technology to another, only WishlistRepository changes. Every business rule in WishlistService — the duplicate check, whatever future rules get added — stays untouched. You're back to Episode 3's test: can you change one thing without reopening a box that already works?

👦 Nephew: And if I don't split it?

👨‍🦳 Uncle: Then database queries and business rules sit in the same functions, tangled together. Testing addItem's duplicate logic now means spinning up a real database just to check a rule that has nothing to do with storage. That's usually the first sign a class has been doing two jobs it should never have shared.


Shape → Flow → Split

👦 Nephew: Give me the framework.

👨‍🦳 Uncle:

Shape
  ↓
Flow
  ↓
Split
Enter fullscreen mode Exit fullscreen mode

Shape — for every function in the box: name, parameters, return type, and every distinct way it can fail. Not code — just the signature and the contract it promises.

Flow — trace what happens step by step inside. Where does input validation happen, versus a business rule? When something fails, does it throw or return, and which box is actually allowed to decide what that failure means to the outside world?

Split — look at what each function actually depends on. Group what shares a dependency. Separate what doesn't. If testing one piece of logic secretly requires a database, a network call, or another service, that's usually a sign it's tangled with something it shouldn't be.


Uncle's Line

"HLD decides which rooms exist. LLD decides what furniture goes in each room, and makes sure nobody has to knock down a wall just to move a chair."


👦 Nephew: So this is everything, before code?

👨‍🦳 Uncle: Almost everything. Today you designed what's inside the box. You didn't decide the exact shape of the API request hitting it from outside, and you didn't decide how the table behind it is actually structured — field by field, what's indexed, what's normalized. Those are real enough to be their own episodes, and they're coming. So is how you'd watch this box once it's live in production — but that's much further ahead, once there's actually something running to watch.

👦 Nephew: And after all of that?

👨‍🦳 Uncle: After all of that — finally, VS Code.


🧠 Think Like an Engineer — Homework

Pick the same feature you've been sketching an HLD for since Episode 3. Zoom into its main service — just that one box.

  • Shape — write the function signatures it needs: names, parameters, return types, and every distinct way each one can fail.
  • Flow — for one function, trace it step by step. Mark clearly which checks are input validation, and which are business rules specific to this feature.
  • Split — look at your function list. Is there a natural seam where one group needs the database and another doesn't? Would splitting them into two classes make either one easier to test on its own?

Don't worry about writing real code yet. The goal is to practice designing a box's insides on paper, before a single line commits you to a shape you didn't choose deliberately.

— End of Episode 4 —

Top comments (0)