Low Level Design (LLD) in One Shot – Complete Guide for Interviews & Real-World Systems

    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.

    default profile

    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.


    1. LLD Fundamentals#

    What is LLD#

    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.

    LLD vs HLD#

    AspectHLD (High Level Design)LLD (Low Level Design)
    FocusSystem architectureComponent internals
    ScopeMacro (services, databases, infra)Micro (classes, methods, patterns)
    AudienceArchitects, stakeholdersEngineers, tech leads
    OutputArchitecture diagrams, service contractsClass diagrams, code structure
    ToolsBlock diagrams, sequence diagramsUML class diagrams, code
    Interview RoundSystem design roundMachine coding / OOP round

    Importance in Interviews and Real Systems#

    In FAANG and product-based interviews, the LLD round evaluates:

    • Whether you can identify the right abstractions
    • Whether your code is extensible without modification
    • Whether you understand design patterns and know when NOT to use them
    • Whether your classes have clear, single responsibilities

    In real systems, poor LLD leads to:

    • God classes that nobody wants to touch
    • Tight coupling that makes testing a nightmare
    • Impossible-to-extend code whenever requirements change

    Clean Code Principles#

    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.

    2. Object-Oriented Programming (OOP)#

    Four pillars of OOP

    Class and Object#

    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; } } // Object BankAccount 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.

    Encapsulation#

    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.

    // BAD public class Order { public double total; // Anyone can mutate this } // GOOD public 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; } }

    Abstraction#

    Expose what something does, hide how it does it. Achieved through interfaces and abstract classes.

    public interface PaymentGateway { boolean processPayment(double amount, String currency); } public class StripeGateway implements PaymentGateway { @Override public boolean processPayment(double amount, String currency) { // Stripe-specific HTTP calls, token handling, etc. return true; } }

    The caller only knows about PaymentGateway. It does not care whether it is Stripe, Razorpay, or PayPal underneath.

    Inheritance#

    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.

    Polymorphism#

    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 dispatch Notification 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.


    3. SOLID Principles#

    SOLID principles

    S - Single Responsibility Principle (SRP)#

    A class should have one and only one reason to change.

    // BAD - This class does too much public class UserService { public void createUser(User user) { /* ... */ } public void sendWelcomeEmail(User user) { /* ... */ } public void generateUserReport(User user) { /* ... */ } } // GOOD - Separate concerns public 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.

    O - Open/Closed Principle (OCP)#

    Classes should be open for extension, closed for modification.

    // BAD - Adding a new discount type means modifying this class public 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 } } // GOOD public 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.

    L - Liskov Substitution Principle (LSP)#

    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 LSP public 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 instead public 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.

    I - Interface Segregation Principle (ISP)#

    Clients should not be forced to depend on interfaces they do not use.

    // BAD - Forced to implement irrelevant methods public interface Worker { void work(); void eat(); void sleep(); } public class RobotWorker implements Worker { public void work() { /* ... */ } public void eat() { throw new UnsupportedOperationException(); } // Robots don't eat public void sleep() { throw new UnsupportedOperationException(); } } // GOOD - Split into focused interfaces public interface Workable { void work(); } public interface HumanNeeds { void eat(); void sleep(); } public class HumanWorker implements Workable, HumanNeeds { public void work() { /* ... */ } public void eat() { /* ... */ } public void sleep() { /* ... */ } } public class RobotWorker implements Workable { public void work() { /* ... */ } }

    D - Dependency Inversion Principle (DIP)#

    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 class public class OrderService { private MySQLOrderRepository repository = new MySQLOrderRepository(); // Hardcoded dependency public void placeOrder(Order order) { repository.save(order); } } // GOOD - Depend on abstraction, inject the implementation public 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.


    4. Design Patterns#

    Design patterns are reusable solutions to common design problems. They are not copy-paste templates. They are thinking tools.

    Creational Patterns#

    Singleton#

    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.

    Factory Method#

    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); }; } } // Usage Notification 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.

    Builder#

    Constructs a complex object step by step. Useful when an object has many optional fields and telescoping constructors become unreadable.

    public class UserProfile { private final String userId; private final String name; private final String email; private final String phoneNumber; private final String address; private UserProfile(Builder builder) { this.userId = builder.userId; this.name = builder.name; this.email = builder.email; this.phoneNumber = builder.phoneNumber; this.address = builder.address; } public static class Builder { private final String userId; // Required private final String name; // Required private String email; private String phoneNumber; private String address; public Builder(String userId, String name) { this.userId = userId; this.name = name; } public Builder email(String email) { this.email = email; return this; } public Builder phoneNumber(String phone) { this.phoneNumber = phone; return this; } public Builder address(String address) { this.address = address; return this; } public UserProfile build() { return new UserProfile(this); } } } // Usage - clean, readable, no positional argument confusion UserProfile profile = new UserProfile.Builder("U001", "Alice") .email("alice@example.com") .address("Pune, India") .build();

    Trade-off: More boilerplate. In production, Lombok's @Builder handles this, but knowing the manual pattern is essential for interviews.

    Structural Patterns#

    Adapter#

    Wraps an incompatible interface to make it compatible with what the client expects.

    Real-world analogy: A power plug adapter. The socket interface does not change, your device does not change, but the adapter bridges them.

    // Third-party payment library we cannot modify public class LegacyPaymentSDK { public String makeTransaction(String accountNo, float rupees) { return "SUCCESS"; } } // Interface our system expects public interface PaymentProcessor { boolean processPayment(String accountId, double amount); } // Adapter bridges the gap public class PaymentAdapter implements PaymentProcessor { private final LegacyPaymentSDK legacySDK; public PaymentAdapter(LegacyPaymentSDK sdk) { this.legacySDK = sdk; } @Override public boolean processPayment(String accountId, double amount) { String result = legacySDK.makeTransaction(accountId, (float) amount); return "SUCCESS".equals(result); } }

    Decorator#

    Adds behavior to an object dynamically without modifying its class or creating subclasses for every combination.

    When to use: When you need to stack behaviors, like adding logging, caching, retry logic to a service.

    public interface DataReader { String read(); } public class FileDataReader implements DataReader { public String read() { return "raw file data"; } } public class EncryptedDataReader implements DataReader { private final DataReader wrapped; public EncryptedDataReader(DataReader reader) { this.wrapped = reader; } public String read() { return decrypt(wrapped.read()); } private String decrypt(String data) { return "decrypted: " + data; } } public class CachedDataReader implements DataReader { private final DataReader wrapped; private String cache; public CachedDataReader(DataReader reader) { this.wrapped = reader; } public String read() { if (cache == null) cache = wrapped.read(); return cache; } } // Usage - stacking decorators DataReader reader = new CachedDataReader(new EncryptedDataReader(new FileDataReader())); reader.read();

    Trade-off: Debugging a deeply nested decorator chain can be confusing. Use clear naming and keep the chain shallow.

    Behavioral Patterns#

    Strategy#

    Defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime.

    When to use: When you have multiple ways to do something and you want to switch between them without conditionals.

    public interface SortStrategy { void sort(int[] data); } public class QuickSort implements SortStrategy { public void sort(int[] data) { /* quicksort impl */ } } public class MergeSort implements SortStrategy { public void sort(int[] data) { /* mergesort impl */ } } public class DataProcessor { private SortStrategy strategy; public DataProcessor(SortStrategy strategy) { this.strategy = strategy; } public void setStrategy(SortStrategy strategy) { this.strategy = strategy; } public void process(int[] data) { strategy.sort(data); } }

    Interview insight: Every time you see a chain of if-else or switch based on a "type," ask yourself if Strategy pattern cleans it up.

    Observer#

    Defines a one-to-many dependency so that when one object changes state, all its dependents are notified automatically.

    When to use: Event systems, notification pipelines, pub-sub mechanisms.

    public interface EventListener { void onEvent(String eventType, Object data); } public class EventManager { private final Map<String, List<EventListener>> listeners = new HashMap<>(); public void subscribe(String eventType, EventListener listener) { listeners.computeIfAbsent(eventType, k -> new ArrayList<>()).add(listener); } public void publish(String eventType, Object data) { List<EventListener> eventListeners = listeners.getOrDefault(eventType, Collections.emptyList()); for (EventListener listener : eventListeners) { listener.onEvent(eventType, data); } } } public class OrderService { private final EventManager eventManager; public OrderService(EventManager eventManager) { this.eventManager = eventManager; } public void placeOrder(Order order) { // save order eventManager.publish("ORDER_PLACED", order); } } public class EmailService implements EventListener { public void onEvent(String eventType, Object data) { System.out.println("Sending confirmation email for: " + eventType); } }

    Trade-off: If observers are slow or throw exceptions, they can affect the publisher. Consider async notification for production use.

    Command#

    Encapsulates a request as an object, allowing you to parameterize, queue, log, or undo operations.

    When to use: Undo/redo functionality, job queues, audit trails.

    public interface Command { void execute(); void undo(); } public class TransferMoneyCommand implements Command { private final BankAccount from; private final BankAccount to; private final double amount; public TransferMoneyCommand(BankAccount from, BankAccount to, double amount) { this.from = from; this.to = to; this.amount = amount; } public void execute() { from.debit(amount); to.credit(amount); } public void undo() { to.debit(amount); from.credit(amount); } } public class CommandHistory { private final Deque<Command> history = new ArrayDeque<>(); public void executeCommand(Command cmd) { cmd.execute(); history.push(cmd); } public void undoLast() { if (!history.isEmpty()) history.pop().undo(); } }

    5. Class Design and Relationships#

    UML class diagrams 

    Types of Relationships#

    RelationshipMeaningLifetime DependencyExample
    AssociationClass A uses Class BIndependentTeacher and Student
    AggregationClass A has Class B (weak ownership)B can exist without ADepartment and Employee
    CompositionClass A owns Class B (strong ownership)B cannot exist without AHouse and Room
    DependencyClass A temporarily uses Class BNo ownershipOrderService uses PaymentGateway
    // Association - loose relationship, no ownership public class Driver { private Car car; // Driver can exist without car and vice versa } // Aggregation - weak ownership public class Department { private List<Employee> employees; // Employees can exist even if department is dissolved } // Composition - strong ownership, child cannot exist without parent public 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 use public class InvoiceService { public void generate(PdfGenerator generator, Invoice invoice) { // Uses generator but doesn't own it generator.create(invoice); } }

    UML Class Diagram Basics#

    A class box has three sections:

    +------------------+ | ClassName | +------------------+ | - privateField | | + publicField | +------------------+ | + method(): void | +------------------+
    • - = private, + = public, # = protected
    • Arrow with hollow triangle = Inheritance
    • Dashed arrow = Dependency/Implementation
    • Solid arrow with hollow head = inheritance
    • Dashed arrow = dependency or implements
    • Filled diamond = composition
    • Hollow diamond = aggregation

    When drawing in an interview, label your arrows. A diagram without labels communicates nothing.


    6. Clean Code Practices#

    DRY, KISS, YAGNI#

    PrincipleFull FormWhat It MeansCommon Violation
    DRYDon't Repeat YourselfExtract duplicated logic into reusable unitsCopy-pasting validation logic in every method
    KISSKeep It Simple, StupidSimplest solution that works is bestOver-engineering a problem that needs 10 lines with 200 lines
    YAGNIYou Aren't Gonna Need ItDo not build features until they are neededAdding abstract factories for a system with one concrete type

    Naming Conventions#

    Good names are the cheapest form of documentation.

    WhatConventionBAD ExampleGOOD Example
    ClassPascalCase, nounProcessDataInvoiceProcessor
    MethodcamelCase, verbdata()calculateTotalPrice()
    Boolean variablecamelCase, is/has/can prefixactiveisActive, hasExpired
    ConstantUPPER_SNAKE_CASEmaxRetryMAX_RETRY_COUNT
    InterfacePascalCase, noun or adjectiveIPayablePayable, PaymentProcessor
    Packagelowercase, dot-separatedUserServicecom.company.user.service

    Layered Architecture#

    Controller LayerHandles HTTP, input parsing, response formatting | Service LayerBusiness logic, orchestration, transaction management | Repository LayerData access, persistence, query execution
    // Controller - handles request/response only @RestController public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping("/orders") public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) { OrderDTO order = orderService.createOrder(request); return ResponseEntity.ok(new OrderResponse(order)); } } // Service - owns the business logic @Service public class OrderService { private final OrderRepository orderRepository; private final InventoryService inventoryService; public OrderDTO createOrder(CreateOrderRequest request) { inventoryService.reserve(request.getItems()); Order order = Order.from(request); Order saved = orderRepository.save(order); return OrderDTO.from(saved); } } // Repository - data access only @Repository public interface OrderRepository extends JpaRepository<Order, String> { List<Order> findByCustomerId(String customerId); }

    Common mistake: Putting business logic in controllers or database queries in service classes. These layers exist for a reason.


    7. Common LLD Problems#

    Parking Lot#

    Key requirement: Track available spots, support multiple vehicle types, calculate fare.

    Key classes:

    public enum VehicleType { BIKE, CAR, TRUCK } public class Vehicle { private final String licensePlate; private final VehicleType type; } public class ParkingSpot { private final String spotId; private final VehicleType allowedType; private boolean isOccupied; private Vehicle parkedVehicle; } public class ParkingFloor { private final String floorId; private final List<ParkingSpot> spots; public Optional<ParkingSpot> findAvailableSpot(VehicleType type) { return spots.stream() .filter(s -> s.getAllowedType() == type && !s.isOccupied()) .findFirst(); } } public class ParkingLot { private static ParkingLot instance; // Singleton - one lot per JVM private final List<ParkingFloor> floors; private final FareCalculator fareCalculator; public Ticket park(Vehicle vehicle) { for (ParkingFloor floor : floors) { Optional<ParkingSpot> spot = floor.findAvailableSpot(vehicle.getType()); if (spot.isPresent()) { spot.get().park(vehicle); return new Ticket(vehicle, spot.get(), LocalDateTime.now()); } } throw new ParkingLotFullException("No available spot for: " + vehicle.getType()); } } public interface FareCalculator { double calculate(Ticket ticket); } public class HourlyFareCalculator implements FareCalculator { private final Map<VehicleType, Double> ratePerHour; public double calculate(Ticket ticket) { long hours = ChronoUnit.HOURS.between(ticket.getEntryTime(), LocalDateTime.now()); return ratePerHour.get(ticket.getVehicle().getType()) * Math.max(1, hours); } }

    Design decisions:

    • ParkingLot is Singleton because there is only one physical lot
    • FareCalculator is an interface (Strategy pattern) so pricing logic can change without touching ParkingLot
    • findAvailableSpot returns Optional to force callers to handle the "no spot" case explicitly

    Library Management System#

    Key classes: Book, BookCopy, Member, Loan, LibraryCatalog, LoanService

    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.

    Elevator System#

    Key classes: Elevator, ElevatorController, Request, ElevatorScheduler

    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.

    Splitwise#

    Key classes: User, Expense, Split, Balance, ExpenseService

    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.

    Shopping Cart#

    Key classes: Product, CartItem, Cart, PricingEngine, OrderService

    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); } }

    8. Error Handling and Logging#

    Exception Handling Best Practices#

    • Catch specific exceptions, not Exception or Throwable
    • Never silently swallow exceptions
    • Clean up resources in finally or use try-with-resources
    • Fail fast: validate inputs at system boundaries
    // BAD try { processOrder(order); } catch (Exception e) { // silently ignored - debugging nightmare } // GOOD try { processOrder(order); } catch (InsufficientInventoryException e) { log.warn("Inventory check failed for order {}: {}", order.getId(), e.getMessage()); throw new OrderCreationException("Product out of stock", e); } catch (PaymentFailedException e) { log.error("Payment failed for order {}", order.getId(), e); notificationService.alertUser(order.getUserId(), "Payment failed"); throw e; }

    Custom Exceptions#

    Design your exception hierarchy to mirror your domain.

    // Base exception for the domain public class ApplicationException extends RuntimeException { private final String errorCode; public ApplicationException(String message, String errorCode) { super(message); this.errorCode = errorCode; } public ApplicationException(String message, String errorCode, Throwable cause) { super(message, cause); this.errorCode = errorCode; } public String getErrorCode() { return errorCode; } } public class ResourceNotFoundException extends ApplicationException { public ResourceNotFoundException(String resource, String id) { super(resource + " not found with id: " + id, "RESOURCE_NOT_FOUND"); } } public class ValidationException extends ApplicationException { private final List<String> violations; public ValidationException(List<String> violations) { super("Validation failed", "VALIDATION_ERROR"); this.violations = violations; } }

    Logging Levels#

    LevelWhen to UseExample
    ERRORUnexpected failure, needs immediate attentionPayment gateway down, DB unreachable
    WARNSomething unusual happened but system is still runningRetry attempt, deprecated API used
    INFONormal but significant operationsOrder placed, user logged in
    DEBUGDetailed trace for developmentRequest payload, SQL query
    TRACEVery granular, almost never in productionMethod entry/exit
    // Structured logging - always include context log.info("Order created successfully | orderId={} userId={} amount={}", order.getId(), order.getUserId(), order.getTotal()); log.error("Payment failed | orderId={} gateway={} errorCode={}", order.getId(), gateway.getName(), response.getErrorCode()); // NEVER log sensitive data // BAD: log.info("User logged in | password={}", password); // GOOD: log.info("User logged in | userId={}", userId);

    9. Concurrency Basics#

    Race condition

    Threads vs Processes#

    AspectProcessThread
    MemorySeparate memory spaceShared memory within process
    CommunicationIPC (sockets, pipes)Shared variables, synchronized blocks
    Creation costHighLow
    IsolationFull isolationShared state can cause bugs

    Race Condition#

    When two threads access shared mutable state concurrently and the outcome depends on the timing of execution.

    // BAD - race condition on counter public class Counter { private int count = 0; public void increment() { count++; // Read-increment-write is NOT atomic } } // GOOD - use AtomicInteger for single variable public class Counter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); // Atomic operation } }

    Synchronization#

    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; } } } }

    Deadlock#

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

    1. Mutual exclusion
    2. Hold and wait
    3. No preemption
    4. 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.

    Thread-Safe Design#

    // Prefer immutable objects - they are inherently thread-safe public 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 collections private final Map<String, Session> sessions = new ConcurrentHashMap<>(); private final BlockingQueue<Task> taskQueue = new LinkedBlockingQueue<>(); // Use ExecutorService instead of raw threads ExecutorService 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.


    10. API and Service Layer Design#

    Layered architecture

    DTO vs Entity#

    AspectEntityDTO
    PurposeDatabase representationAPI data transfer
    ContainsDB annotations, relationshipsOnly fields needed for specific use case
    Should expose directly?NeverYes
    SerializationNoYes
    // Entity - internal, mapped to DB table @Entity @Table(name = "users") public class User { @Id private String userId; private String name; private String email; private String passwordHash; // Should NEVER appear in API response private LocalDateTime createdAt; } // DTO - external, safe to return from API public class UserResponseDTO { private String userId; private String name; private String email; // No passwordHash - intentional public static UserResponseDTO from(User user) { UserResponseDTO dto = new UserResponseDTO(); dto.userId = user.getUserId(); dto.name = user.getName(); dto.email = user.getEmail(); return dto; } } // Request DTO - what the API accepts public class CreateUserRequest { @NotBlank(message = "Name is required") private String name; @Email(message = "Invalid email format") @NotBlank private String email; @Size(min = 8, message = "Password must be at least 8 characters") private String password; }

    Validation#

    Validate at the boundary - controllers are the entry point. Do not let invalid data leak into service or domain layers.

    @RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; @PostMapping public ResponseEntity<UserResponseDTO> createUser( @Valid @RequestBody CreateUserRequest request) { // @Valid triggers Bean Validation UserResponseDTO user = userService.createUser(request); return ResponseEntity.status(HttpStatus.CREATED).body(user); } } // Global exception handler @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) { List<String> errors = ex.getBindingResult().getFieldErrors().stream() .map(e -> e.getField() + ": " + e.getDefaultMessage()) .collect(Collectors.toList()); return ResponseEntity.badRequest().body(new ErrorResponse("VALIDATION_ERROR", errors)); } @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(new ErrorResponse(ex.getErrorCode(), ex.getMessage())); } }

    Dependency Injection#

    Do not create dependencies inside classes. Inject them from outside.

    // Constructor injection - preferred over field injection (@Autowired on field) @Service public 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.


    11. Testing Basics#

    Unit Testing#

    Tests a single unit of logic in isolation. Dependencies are mocked.

    @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private OrderRepository orderRepository; @Mock private InventoryService inventoryService; @InjectMocks private OrderService orderService; @Test void placeOrder_shouldSaveOrder_whenInventoryAvailable() { // Arrange Order order = new Order("ORD001", "USER001", List.of(new OrderItem("PROD1", 2))); when(inventoryService.isAvailable("PROD1", 2)).thenReturn(true); when(orderRepository.save(any(Order.class))).thenReturn(order); // Act Order result = orderService.placeOrder(order); // Assert assertNotNull(result); assertEquals("ORD001", result.getOrderId()); verify(inventoryService).reserve("PROD1", 2); verify(orderRepository).save(order); } @Test void placeOrder_shouldThrow_whenInventoryInsufficient() { Order order = new Order("ORD001", "USER001", List.of(new OrderItem("PROD1", 100))); when(inventoryService.isAvailable("PROD1", 100)).thenReturn(false); assertThrows(InsufficientInventoryException.class, () -> orderService.placeOrder(order)); verify(orderRepository, never()).save(any()); // Order should not be saved } }

    Writing Testable Code#

    PracticeWhy It Helps Testing
    Constructor injectionCan pass mock implementations in tests
    Program to interfacesEasy to swap real with mock
    Small, focused methodsEach method has one thing to verify
    Avoid static callsStatic methods cannot be mocked easily
    Avoid new inside business logicHard to control object creation in tests

    Test Structure: Arrange-Act-Assert#

    Always structure tests in three clear sections:

    @Test void 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.


    Quick Reference Summary#

    CategoryKey Takeaway
    SOLIDSRP keeps classes focused, OCP prevents modification, DIP enables testability
    PatternsSingleton for shared resources, Strategy for swappable algorithms, Observer for events
    RelationshipsPrefer composition over inheritance, keep dependencies explicit
    Clean CodeName things clearly, keep methods small, one level of abstraction per method
    ConcurrencyPrefer immutability, use AtomicInteger for counters, lock in consistent order
    TestingTest behavior not implementation, inject dependencies, Arrange-Act-Assert
    LayeringValidation in controllers, logic in services, data access in repositories
    LLD ProblemsIdentify entities, relationships, and patterns before writing any code

    Conclusion#

    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

    Learn more
    System Design
    Low Level Design
    Backend Development
    Was it helpful?

    Subscribe to our newsletter

    Read articles from Coding Shuttle directly inside your inbox. Subscribe to the newsletter, and don't miss out.

    More articles