Introduction to low level design
In this series (15 parts)
- Introduction to low level design
- SOLID principles
- Design patterns: Creational
- Design patterns: Structural
- Design patterns: Behavioral
- Designing a parking lot
- Designing a library management system
- Designing an elevator system
- Designing a hotel booking system
- Designing a ride-sharing model
- Designing a rate limiter
- Designing a logging framework
- Designing a notification system
- API design and contract-first development
- Data modeling for system design
System design conversations typically split into two layers. High level design (HLD) picks the boxes: which services exist, how they communicate, what databases back them. Low level design (LLD) zooms into a single box and answers a harder question: what classes, interfaces, and relationships live inside it, and how do they collaborate to satisfy the requirements?
If HLD is the floor plan of a building, LLD is the wiring diagram for one room. You need both. A beautiful architecture with sloppy internals still ships bugs.
What LLD actually produces
An LLD discussion usually results in three artifacts:
- Class diagrams showing entities, their attributes, and their relationships.
- Interface contracts that define how components talk to each other without leaking implementation details.
- Data models that map classes to storage (tables, documents, key structures).
The interviewer (or your future self reading the design doc) should be able to open an IDE and start coding from the LLD output. That is the bar.
classDiagram
class Library {
+String name
+List~Book~ catalog
+addBook(Book)
+searchByTitle(String) List~Book~
}
class Book {
+String isbn
+String title
+Author author
+boolean isAvailable
}
class Author {
+String name
+String bio
}
class Member {
+String memberId
+String name
+borrowBook(Book)
+returnBook(Book)
}
class BorrowRecord {
+Book book
+Member member
+Date borrowDate
+Date dueDate
}
Library "1" --> "*" Book : contains
Book --> Author
Member "1" --> "*" BorrowRecord : has
BorrowRecord --> Book
A simple library management LLD. Every class has typed attributes and methods; every relationship has cardinality. This is what “LLD output” looks like.
Notice the diagram is not a vague cloud of boxes connected by arrows. Each class has concrete fields, concrete methods, and directional associations with multiplicity. That precision is what separates LLD from a whiteboard sketch.
The gap between HLD and code
Consider a notification service. At HLD level you might say “there is a notification service that pushes alerts to users via email, SMS, and push notifications.” That sentence leaves dozens of questions open:
- How do you represent a notification? What fields does it carry?
- How do you pick the right channel (email vs SMS vs push)?
- How do you add a new channel without rewriting the dispatch logic?
- What happens when a channel fails?
LLD answers all of these by defining classes, their responsibilities, and their contracts. The design below shows one possible answer.
classDiagram
class Notification {
+String id
+String userId
+String message
+NotificationType type
+Date createdAt
}
class NotificationChannel {
<<interface>>
+send(Notification) boolean
}
class EmailChannel {
+send(Notification) boolean
}
class SMSChannel {
+send(Notification) boolean
}
class PushChannel {
+send(Notification) boolean
}
class NotificationService {
-Map~NotificationType, NotificationChannel~ channels
+dispatch(Notification) boolean
+registerChannel(NotificationType, NotificationChannel)
}
NotificationChannel <|.. EmailChannel
NotificationChannel <|.. SMSChannel
NotificationChannel <|.. PushChannel
NotificationService --> NotificationChannel
NotificationService --> Notification
Notification service LLD. The NotificationChannel interface lets you add channels without modifying the dispatcher.
Adding a WhatsApp channel now requires one new class that implements NotificationChannel and one call to registerChannel. The dispatch logic never changes. That is the payoff of doing LLD properly.
OOP fundamentals: the building blocks
LLD leans heavily on object oriented programming concepts. You do not need to memorize textbook definitions, but you do need working fluency with four ideas.
Encapsulation
Encapsulation means bundling data and the methods that operate on that data into a single unit, and controlling access to the internals. A BankAccount class exposes deposit() and withdraw() but keeps balance private. External code cannot set the balance to a negative number because it has no direct access; it must go through the methods that enforce the rules.
The practical benefit: when the internal representation changes (say, you switch from a single balance field to a ledger of transactions), callers do not break because they never depended on the internal structure.
Inheritance
Inheritance models “is-a” relationships. A SavingsAccount is a BankAccount with an additional interest rate. Instead of duplicating every field and method, SavingsAccount extends BankAccount and adds only what is new.
Use inheritance sparingly. Deep hierarchies become brittle. Prefer composition (has-a) over inheritance (is-a) when the relationship is not truly hierarchical. A Car has an Engine; it is not a subtype of Engine.
Polymorphism
Polymorphism lets you write code that works with a base type and automatically handles all subtypes. The NotificationService above calls channel.send(notification) without knowing whether channel is an EmailChannel or an SMSChannel. The right implementation runs at call time.
This is the single most useful OOP concept for LLD. Almost every good design relies on polymorphism to keep components loosely coupled.
Abstraction
Abstraction hides complexity behind a simpler interface. When you call list.sort(), you do not care whether the implementation uses quicksort or timsort. The abstraction (the sort method signature) shields you from the algorithm choice.
In LLD, abstraction shows up as interfaces and abstract classes. They define what something does without prescribing how it does it.
How to approach an LLD problem
A repeatable process helps you stay structured under time pressure.
Step 1: Clarify requirements. Identify the core use cases. For a parking lot: vehicles enter, park, pay, leave. Nail down the entities (Vehicle, ParkingSpot, Ticket, Payment) before you draw anything.
Step 2: Identify classes and relationships. Nouns in the requirements often become classes. Verbs become methods. “A member borrows a book” gives you Member, Book, and borrowBook().
Step 3: Define interfaces. Wherever you see a family of similar things (payment methods, notification channels, file storage backends), extract an interface. This keeps the design extensible.
Step 4: Assign responsibilities. Each class should have one clear reason to exist. If a class is doing two unrelated things, split it. This idea becomes formalized as the Single Responsibility Principle, covered in the next article.
Step 5: Validate with a walkthrough. Pick a use case and trace it through your classes. “User creates an order” should touch User, Order, OrderItem, PaymentProcessor, etc., each doing its part. If you hit a dead end, the model is missing something.
A concrete example: online bookstore
Let us walk through the process for a small online bookstore.
Requirements: users browse books, add them to a cart, place orders, and pay. The store tracks inventory.
classDiagram
class User {
+String userId
+String email
+Cart cart
+placeOrder() Order
}
class Cart {
+List~CartItem~ items
+addItem(Book, int quantity)
+removeItem(Book)
+getTotal() double
}
class CartItem {
+Book book
+int quantity
}
class Book {
+String isbn
+String title
+double price
+int stockCount
}
class Order {
+String orderId
+User user
+List~OrderItem~ items
+double totalAmount
+OrderStatus status
}
class OrderItem {
+Book book
+int quantity
+double priceAtPurchase
}
class PaymentProcessor {
<<interface>>
+processPayment(Order, PaymentDetails) boolean
}
class StripePayment {
+processPayment(Order, PaymentDetails) boolean
}
User "1" --> "1" Cart
Cart "1" --> "*" CartItem
CartItem --> Book
User "1" --> "*" Order
Order "1" --> "*" OrderItem
OrderItem --> Book
PaymentProcessor <|.. StripePayment
Online bookstore class diagram. Note the separation between CartItem (pre-purchase) and OrderItem (post-purchase, with a frozen price).
A few design decisions worth calling out:
OrderItemstorespriceAtPurchaseseparately fromBook.price. Prices change; the order record should not change retroactively.PaymentProcessoris an interface, making it trivial to swap Stripe for another provider later.Cartis a separate class fromUsereven though every user has exactly one cart. This keeps cart logic (totaling, adding, removing) out of the user class.
Common mistakes
Skipping straight to code. If you jump into writing classes without sketching relationships, you will paint yourself into a corner. Spend five minutes on the diagram first.
God classes. A single class that manages users, handles payments, and sends emails is a maintenance nightmare. If your class has fifteen methods doing unrelated things, break it apart.
Ignoring extensibility. Ask yourself: “What if the requirements change?” If adding a new payment method requires editing five existing classes, the design is too rigid.
Over-engineering. Not every problem needs the Strategy pattern, three layers of abstraction, and a plugin system. Design for the requirements you have, plus one plausible extension point. Do not design for requirements you imagine.
What comes next
Now that you have a feel for what LLD produces and the OOP tools you will use, the next article on SOLID principles formalizes the guidelines that keep class designs clean. SOLID is the compass that tells you when a design is heading in the wrong direction, before the codebase gets too large to refactor easily.