Low Level Design (LLD) in One Shot – Complete Guide for Interviews & Real-World Systems
A practical cheatsheet for mastering Low Level Design (LLD), covering OOP, SOLID, design patterns, and clean code. Built for interview preparation and real-world system design.
Shreyash Gurav
March 23, 2026
28 min read
Low Level Design (LLD) in One Shot – Complete Guide for Interviews & Real-World Systems
A complete and practical guide to Low Level Design (LLD), covering OOP principles, SOLID, design patterns, class relationships, and real-world system design problems. This cheatsheet is designed to help backend developers and interview candidates build clean, scalable, and maintainable code with confidence.
Low Level Design is the process of defining the internal structure of individual components in a system. It answers the question: how will each module be built, not just what it will do.
While High Level Design (HLD) tells you that a system has a "Notification Service," LLD tells you what classes exist inside it, how they communicate, what patterns they use, and how edge cases are handled.
In interviews, LLD tests your ability to translate requirements into clean, extensible, object-oriented code. In real systems, good LLD is what separates a codebase that survives scaling from one that collapses under its own weight.
These are not optional niceties. They are the baseline expectation.
Readability: Code is read far more than it is written. Name things clearly. A method called processData() tells you nothing. calculateInvoiceTotal() tells you everything.
Maintainability: Can a new engineer fix a bug without understanding the entire codebase? If not, your design has failed.
Extensibility: Can you add a new payment method without touching existing payment code? Good LLD makes this possible.
A class is a blueprint. An object is a live instance of that blueprint with its own state.
public class BankAccount { private String accountId; private double balance; public BankAccount(String accountId, double initialBalance) { this.accountId = accountId; this.balance = initialBalance; } public double getBalance() { return balance; }}// ObjectBankAccount account = new BankAccount("ACC001", 5000.0);
Common mistake: Treating classes as data bags with no behavior. If your class only has getters/setters and no meaningful methods, it is an anemic domain model - a known anti-pattern.
Hide internal state. Expose only what is necessary through a controlled interface.
Why it matters: If balance is public, any part of the system can directly set it to -99999. Encapsulation prevents this.
// BADpublic class Order { public double total; // Anyone can mutate this}// GOODpublic class Order { private double total; public void addItem(double price) { if (price <= 0) throw new IllegalArgumentException("Price must be positive"); this.total += price; } public double getTotal() { return total; }}
A subclass inherits state and behavior from a parent class. Use it when a genuine "is-a" relationship exists.
public class Vehicle { protected String brand; protected int speed; public void accelerate(int amount) { this.speed += amount; }}public class Car extends Vehicle { private int numberOfDoors; public Car(String brand, int numberOfDoors) { this.brand = brand; this.numberOfDoors = numberOfDoors; }}
Trade-off: Inheritance creates tight coupling between parent and child. Prefer composition over inheritance when the relationship is behavioral, not structural. If you are inheriting just to reuse methods, use composition instead.
Compile-time (Static): Method overloading - same method name, different parameter signatures, resolved at compile time.
public class Calculator { public int add(int a, int b) { return a + b; } public double add(double a, double b) { return a + b; }}
Runtime (Dynamic): Method overriding - subclass provides its own implementation of a parent method, resolved at runtime.
public abstract class Notification { public abstract void send(String message);}public class EmailNotification extends Notification { @Override public void send(String message) { System.out.println("Sending email: " + message); }}public class SMSNotification extends Notification { @Override public void send(String message) { System.out.println("Sending SMS: " + message); }}// Runtime dispatchNotification notif = new EmailNotification();notif.send("Your order is confirmed"); // Calls EmailNotification's implementation
Interview insight: When an interviewer asks you to add a new notification type, the right answer is to create a new subclass, not to add an if-else block. That is polymorphism at work.
A class should have one and only one reason to change.
// BAD - This class does too muchpublic class UserService { public void createUser(User user) { /* ... */ } public void sendWelcomeEmail(User user) { /* ... */ } public void generateUserReport(User user) { /* ... */ }}// GOOD - Separate concernspublic class UserService { public void createUser(User user) { /* ... */ }}public class EmailService { public void sendWelcomeEmail(User user) { /* ... */ }}public class ReportService { public void generateUserReport(User user) { /* ... */ }}
Why it matters: When email logic changes, you do not want to risk breaking user creation logic. They are separate concerns and belong in separate classes.
Classes should be open for extension, closed for modification.
// BAD - Adding a new discount type means modifying this classpublic class DiscountCalculator { public double calculate(String discountType, double price) { if (discountType.equals("SEASONAL")) return price * 0.9; if (discountType.equals("EMPLOYEE")) return price * 0.7; return price; // Adding new type = modifying this method }}// GOODpublic interface DiscountStrategy { double apply(double price);}public class SeasonalDiscount implements DiscountStrategy { public double apply(double price) { return price * 0.9; }}public class EmployeeDiscount implements DiscountStrategy { public double apply(double price) { return price * 0.7; }}public class DiscountCalculator { public double calculate(DiscountStrategy strategy, double price) { return strategy.apply(price); }}
Adding a new discount type now means creating a new class, not touching existing code.
If S is a subtype of T, objects of type T should be replaceable with objects of type S without breaking the program.
// BAD - Violates LSPpublic class Rectangle { protected int width, height; public void setWidth(int w) { this.width = w; } public void setHeight(int h) { this.height = h; } public int area() { return width * height; }}public class Square extends Rectangle { @Override public void setWidth(int w) { this.width = w; this.height = w; } // Breaks contract @Override public void setHeight(int h) { this.width = h; this.height = h; }}// Code that sets width=5, height=10 and expects area=50 breaks when given a Square// GOOD - Use a common interface insteadpublic interface Shape { int area();}public class Rectangle implements Shape { private int width, height; public Rectangle(int width, int height) { this.width = width; this.height = height; } public int area() { return width * height; }}public class Square implements Shape { private int side; public Square(int side) { this.side = side; } public int area() { return side * side; }}
Key test: If you have to override a method and throw an exception or change behavior in a way that breaks caller assumptions, you are violating LSP.
High-level modules should not depend on low-level modules. Both should depend on abstractions.
// BAD - High-level class depends on a concrete low-level classpublic class OrderService { private MySQLOrderRepository repository = new MySQLOrderRepository(); // Hardcoded dependency public void placeOrder(Order order) { repository.save(order); }}// GOOD - Depend on abstraction, inject the implementationpublic interface OrderRepository { void save(Order order);}public class OrderService { private final OrderRepository repository; public OrderService(OrderRepository repository) { // Injected from outside this.repository = repository; } public void placeOrder(Order order) { repository.save(order); }}
Now you can swap MySQLOrderRepository with MongoOrderRepository or a mock in tests without touching OrderService.
Ensures only one instance of a class exists throughout the application lifecycle.
When to use: Shared resources like configuration, connection pools, logging instances.
When NOT to use: If you find yourself using Singleton everywhere, you are probably hiding poor dependency management.
public class ConfigManager { private static volatile ConfigManager instance; private final Map<String, String> configs = new HashMap<>(); private ConfigManager() { // Load configs from file or env } public static ConfigManager getInstance() { if (instance == null) { synchronized (ConfigManager.class) { if (instance == null) { instance = new ConfigManager(); } } } return instance; } public String get(String key) { return configs.getOrDefault(key, ""); }}
Common mistake: Not using volatile and double-checked locking. Without volatile, the JVM can reorder instructions and return a partially constructed object.
Trade-off: Makes unit testing hard because you cannot easily inject a mock. Prefer dependency injection over Singleton wherever possible.
Delegates the creation of objects to subclasses or dedicated factory methods. Decouples object creation from usage.
When to use: When the exact type of object to create depends on runtime conditions.
public interface Notification { void send(String message);}public class EmailNotification implements Notification { public void send(String message) { System.out.println("Email: " + message); }}public class PushNotification implements Notification { public void send(String message) { System.out.println("Push: " + message); }}public class NotificationFactory { public static Notification create(String type) { return switch (type) { case "EMAIL" -> new EmailNotification(); case "PUSH" -> new PushNotification(); default -> throw new IllegalArgumentException("Unknown notification type: " + type); }; }}// UsageNotification notif = NotificationFactory.create("EMAIL");notif.send("Your order shipped");
Trade-off: Adding a new type means modifying the factory. Use an abstract factory or registry pattern if you have many types or need plugin-style extensibility.
// Association - loose relationship, no ownershippublic class Driver { private Car car; // Driver can exist without car and vice versa}// Aggregation - weak ownershippublic class Department { private List<Employee> employees; // Employees can exist even if department is dissolved}// Composition - strong ownership, child cannot exist without parentpublic class House { private final List<Room> rooms; // Rooms are created and destroyed with the house public House(int numberOfRooms) { this.rooms = new ArrayList<>(); for (int i = 0; i < numberOfRooms; i++) { rooms.add(new Room()); } }}// Dependency - method-level, temporary usepublic class InvoiceService { public void generate(PdfGenerator generator, Invoice invoice) { // Uses generator but doesn't own it generator.create(invoice); }}
public class Book { private final String isbn; private final String title; private final String author;}public class BookCopy { private final String copyId; private final Book book; private boolean isAvailable;}public class Member { private final String memberId; private final List<Loan> activeLoans; public boolean canBorrow() { return activeLoans.size() < MAX_LOANS_ALLOWED; }}public class Loan { private final BookCopy copy; private final Member member; private final LocalDate borrowDate; private LocalDate returnDate; private LoanStatus status;}public class LoanService { private final LoanRepository loanRepository; private final NotificationService notificationService; public Loan issueBook(Member member, BookCopy copy) { if (!member.canBorrow()) throw new LoanLimitExceededException(member.getMemberId()); if (!copy.isAvailable()) throw new BookNotAvailableException(copy.getCopyId()); copy.setAvailable(false); Loan loan = new Loan(copy, member, LocalDate.now()); loanRepository.save(loan); notificationService.sendBorrowConfirmation(member, copy.getBook()); return loan; }}
Design decisions:
Separate Book (logical entity) from BookCopy (physical instance). A library has one "Clean Code" book but may have 5 physical copies.
canBorrow() is a domain method on Member, not a service-level check. Domain logic belongs in domain objects.
public enum Direction { UP, DOWN, IDLE }public class Elevator { private final int elevatorId; private int currentFloor; private Direction direction; private ElevatorStatus status; private final TreeSet<Integer> floorsToVisit = new TreeSet<>(); public void addFloorRequest(int floor) { floorsToVisit.add(floor); } public void move() { if (floorsToVisit.isEmpty()) { direction = Direction.IDLE; return; } int nextFloor = direction == Direction.UP ? floorsToVisit.first() : floorsToVisit.last(); currentFloor = nextFloor; floorsToVisit.remove(nextFloor); }}public interface ElevatorScheduler { Elevator selectElevator(List<Elevator> elevators, int requestedFloor, Direction direction);}public class NearestElevatorScheduler implements ElevatorScheduler { public Elevator selectElevator(List<Elevator> elevators, int floor, Direction direction) { return elevators.stream() .min(Comparator.comparingInt(e -> Math.abs(e.getCurrentFloor() - floor))) .orElseThrow(); }}
Design decisions:
ElevatorScheduler is an interface. You can swap in SCAN algorithm, LOOK algorithm, or nearest-first without changing elevator internals.
TreeSet for floor requests because it keeps floors sorted, making directional traversal efficient.
public abstract class Split { protected final User user; protected double amount; public abstract void calculateAmount(double totalAmount, int numberOfPeople);}public class EqualSplit extends Split { public EqualSplit(User user) { super(user); } public void calculateAmount(double total, int people) { this.amount = total / people; }}public class PercentSplit extends Split { private final double percent; public PercentSplit(User user, double percent) { super(user); this.percent = percent; } public void calculateAmount(double total, int people) { this.amount = total * percent / 100; }}public class Expense { private final String expenseId; private final User paidBy; private final double totalAmount; private final List<Split> splits; private final String description;}public class BalanceService { // Returns simplified balances: who owes whom and how much public Map<User, Map<User, Double>> calculateBalances(List<Expense> expenses) { Map<User, Map<User, Double>> balances = new HashMap<>(); for (Expense expense : expenses) { User payer = expense.getPaidBy(); for (Split split : expense.getSplits()) { if (!split.getUser().equals(payer)) { balances.computeIfAbsent(split.getUser(), k -> new HashMap<>()) .merge(payer, split.getAmount(), Double::sum); } } } return balances; }}
Design decisions:
Split is abstract. Different split types (equal, percentage, exact) are subclasses. Adding a new split type does not touch existing code.
public class Product { private final String productId; private final String name; private final double basePrice; private final int stockCount;}public class CartItem { private final Product product; private int quantity; public double getSubtotal() { return product.getBasePrice() * quantity; }}public class Cart { private final String cartId; private final String userId; private final Map<String, CartItem> items = new LinkedHashMap<>(); public void addItem(Product product, int quantity) { items.merge(product.getProductId(), new CartItem(product, quantity), (existing, newItem) -> { existing.increaseQuantity(quantity); return existing; }); } public double getTotal() { return items.values().stream().mapToDouble(CartItem::getSubtotal).sum(); }}public interface PricingRule { double apply(Cart cart);}public class CouponDiscountRule implements PricingRule { private final String couponCode; private final double discountPercent; public double apply(Cart cart) { return cart.getTotal() * (1 - discountPercent / 100); }}
When two threads access shared mutable state concurrently and the outcome depends on the timing of execution.
// BAD - race condition on counterpublic class Counter { private int count = 0; public void increment() { count++; // Read-increment-write is NOT atomic }}// GOOD - use AtomicInteger for single variablepublic class Counter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); // Atomic operation }}
Use synchronized for compound operations or multi-variable state that must be consistent.
public class BankAccount { private double balance; private final Object lock = new Object(); public void transfer(BankAccount target, double amount) { // Always lock in consistent order to prevent deadlock BankAccount first = this.hashCode() < target.hashCode() ? this : target; BankAccount second = first == this ? target : this; synchronized (first.lock) { synchronized (second.lock) { if (this.balance < amount) throw new InsufficientFundsException(); this.balance -= amount; target.balance += amount; } } }}
Occurs when two or more threads are each waiting for a lock held by the other, resulting in a permanent standstill.
Four conditions required (remove any one to prevent deadlock):
Mutual exclusion
Hold and wait
No preemption
Circular wait
Prevention: Always acquire locks in a consistent global order. The bank transfer example above uses hash code ordering to ensure lock A is always acquired before lock B.
// Prefer immutable objects - they are inherently thread-safepublic final class Money { private final double amount; private final String currency; public Money(double amount, String currency) { this.amount = amount; this.currency = currency; } public Money add(Money other) { if (!this.currency.equals(other.currency)) throw new CurrencyMismatchException(); return new Money(this.amount + other.amount, this.currency); // Returns new object }}// Use concurrent collectionsprivate final Map<String, Session> sessions = new ConcurrentHashMap<>();private final BlockingQueue<Task> taskQueue = new LinkedBlockingQueue<>();// Use ExecutorService instead of raw threadsExecutorService executor = Executors.newFixedThreadPool(10);executor.submit(() -> processOrder(order));
Modern practices: Prefer java.util.concurrent over raw synchronized. Use CompletableFuture for async pipelines. Keep shared state minimal. Prefer message passing over shared mutable state.
Do not create dependencies inside classes. Inject them from outside.
// Constructor injection - preferred over field injection (@Autowired on field)@Servicepublic class OrderService { private final OrderRepository orderRepository; private final InventoryService inventoryService; private final NotificationService notificationService; // Spring injects these at startup public OrderService(OrderRepository orderRepository, InventoryService inventoryService, NotificationService notificationService) { this.orderRepository = orderRepository; this.inventoryService = inventoryService; this.notificationService = notificationService; }}
Why constructor injection over field injection: Makes dependencies explicit, enables immutability with final, and makes the class testable without a Spring container.
@Testvoid calculateDiscount_shouldApply10Percent_forSeasonalCoupon() { // Arrange - set up the state Cart cart = new Cart("CART001", "USER001"); cart.addItem(new Product("PROD1", "Laptop", 50000.0), 1); DiscountStrategy seasonal = new SeasonalDiscount(10); // Act - execute the behavior being tested double finalPrice = seasonal.apply(cart.getTotal()); // Assert - verify the outcome assertEquals(45000.0, finalPrice, 0.001);}
Common mistake: Testing implementation details instead of behavior. Your test should not care whether calculateDiscount used a loop or a stream internally. It should only verify the output.
Low Level Design is not about memorizing patterns or writing complex code. It is about building systems that are easy to understand, extend, and maintain over time.
If you can apply OOP principles, follow SOLID, choose the right design patterns, and think in terms of trade-offs, you are already ahead of most developers in both interviews and real-world engineering.
Use this cheatsheet as a reference, but focus on practicing real problems and writing clean designs consistently.
If you found this helpful, consider sharing it with your friends and peers who are preparing for backend and system design interviews.
Want to Master Spring Boot and Land Your Dream Job?
Struggling with coding interviews? Learn Data Structures & Algorithms (DSA) with our expert-led course. Build strong problem-solving skills, write optimized code, and crack top tech interviews with ease