Episode 4: Low-Level Design
DEV Community

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()

๐Ÿ‘ฆ 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)

๐Ÿ‘ฆ 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

๐Ÿ‘ฆ 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.

๐Ÿ‘ฆ 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

๐Ÿ‘ฆ 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()

๐Ÿ‘ฆ 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

  • 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 -

Comments

No comments yet. Start the discussion.