4 min read

Low-Level Design (LLD): Writing Code That Future You Won't Hate

Most developers know the sinking feeling of opening a 3,000-line “Manager” or “Helper” file and having no idea what it does or how to change one line without breaking ten others. That is what happens when code gets written without Low-Level Design. In this guide, we demystify LLD with real, relatable examples — showing how to turn messy requirements into clean, testable object models.

👉 Big Picture First: Need to see how your services connect before designing classes? Read our companion guide: High-Level Design (HLD).


What is Low-Level Design, Really?

If High-Level Design (HLD) is the floor plan of an entire apartment building, Low-Level Design (LLD) is the layout of the kitchen: where the refrigerator sits, which way the cabinet doors open, and where the electrical sockets are wired.

In day-to-day engineering, LLD answers the practical questions every developer faces before writing code:

  • What classes and objects do we actually need?
  • Who is responsible for what (and more importantly, who shouldn't be)?
  • Which design pattern makes this code flexible without adding useless complexity?
  • What happens when two users click the same button at the exact same millisecond?

A good LLD is essentially a blueprint. It means you or any other developer on your team can sit down and write clean code on Monday morning without arguing over class names or finding out on Thursday that the database design doesn't fit the business logic.


The Classic LLD Exercise: The Parking Lot

Almost every engineer has faced the famous “Design a Parking Lot” interview question. Why? Because it immediately reveals whether you write spaghetti code or clean, modular systems.

Let's break down how an experienced developer models it step-by-step:

Step 1: Start with the Real-World Entities

Close your laptop for a minute and picture a physical parking garage:

  • A ParkingLot has multiple Floors.
  • Each floor has multiple ParkingSpots (Compact, Large, Handicapped).
  • A Vehicle (Car, Motorcycle, Truck) arrives at an entry gate.
  • The system assigns a spot and prints a ParkingTicket.
  • When leaving, the system calculates the fee based on a PricingStrategy.

classDiagram
    class ParkingLot {
        -String lotId
        -List~ParkingFloor~ floors
        +parkVehicle(Vehicle v) ParkingTicket
        +checkout(ParkingTicket t) Receipt
    }

    class ParkingFloor {
        -int floorNum
        -List~ParkingSpot~ spots
        +findAvailableSpot(VehicleType type) ParkingSpot
    }

    class ParkingSpot {
        -String spotId
        -SpotType spotType
        -boolean isOccupied
        -Vehicle parkedVehicle
        +assign(Vehicle v)
        +vacate()
    }

    class Vehicle {
        <>
        -String licensePlate
        -VehicleType type
        +getType() VehicleType
    }

    class Car {
    }

    class Motorcycle {
    }

    class FeeCalculator {
        <>
        +calculateFee(Duration duration, SpotType type) decimal
    }

    class HourlyRateStrategy {
        +calculateFee(Duration duration, SpotType type) decimal
    }

    ParkingLot *-- ParkingFloor
    ParkingFloor *-- ParkingSpot
    Vehicle <|-- Car
    Vehicle <|-- Motorcycle
    ParkingSpot o-- Vehicle
    ParkingLot --> FeeCalculator
    FeeCalculator <|.. HourlyRateStrategy

Why This Class Structure Works

  • Favor Composition Over Giant Inheritance Trees: A ParkingSpot doesn't inherit from Vehicle. It holds a vehicle reference. This means when a motorcycle pulls out and another vehicle pulls in tomorrow, you don't have to rebuild the spot object.
  • The Strategy Pattern for Pricing: Notice how pricing is handled by a separate FeeCalculator interface. What happens when business management says: “Starting Friday, we want flat-rate $10 pricing for the holiday weekend”? You just create a HolidayFlatRateStrategy class. You don't have to touch or risk breaking ParkingLot or ParkingSpot!

Sequence Flow: What Happens When a Car Arrives?

Static class diagrams only tell half the story. The real bugs happen when objects start talking to each other. Here is the step-by-step sequence when a driver pushes the button at the entry barrier:


sequenceDiagram
    autonumber
    actor Driver as Driver at Entry Gate
    participant Gate as Entry Gate Terminal
    participant Lot as ParkingLot Coordinator
    participant Floor as ParkingFloor (Level 1)
    participant Spot as ParkingSpot (#104)
    participant TicketService as Ticket Generator

    Driver->>Gate: 1. Press "Take Ticket" Button
    activate Gate
    Gate->>Lot: 2. parkVehicle(Car: "ABC-123")
    activate Lot
    Lot->>Floor: 3. Is there a free COMPACT spot?
    activate Floor
    Floor->>Spot: 4. Are you available?
    activate Spot
    Spot-->>Floor: 5. Yes, Spot #104 is vacant!
    deactivate Spot
    Floor-->>Lot: 6. Spot #104 reserved
    deactivate Floor

    Lot->>Spot: 7. Lock and assign Car "ABC-123"
    activate Spot
    Spot-->>Lot: 8. Confirmed
    deactivate Spot

    Lot->>TicketService: 9. Issue ticket for Car on Spot #104
    activate TicketService
    TicketService-->>Gate: 10. Print Ticket (Timestamp: 14:02)
    deactivate TicketService
    deactivate Lot

    Gate-->>Driver: 11. Dispense ticket and raise gate arm
    deactivate Gate

Choosing the Right In-Memory Data Structure

A common trap in LLD is using a simple ArrayList for everything. Let's look at why data structure choices make or break your code:

  • Finding a Free Spot: If a garage has 5,000 spots, looping through an array on every arrival is slow. Instead, keep a Min-Heap (PriorityQueue) of free spots ordered by distance to the entrance. Grabbing the nearest available spot is an instant O(log N) operation.
  • Looking Up an Active Ticket: When a driver scans their ticket at checkout, you need instant lookup. Use a HashMap<TicketId, ParkingTicket> for immediate O(1) retrieval instead of searching through database rows.
  • Handling Two Gates at Once: What if two cars arrive at Entry Gate A and Entry Gate B at the exact same millisecond? If your code isn't thread-safe, both gates might assign Spot #104 to two different cars! Use atomic primitives or thread-safe collections like ConcurrentHashMap to avoid double-booking disasters.

Three Rules to Keep Your Code Clean

  • 1. Single Responsibility: A class should do one job well. If your Car class is also calculating tax rates and connecting to an SQL database, stop immediately and split it up.
  • 2. Never Expose Mutable Collections: If a method does public List<ParkingSpot> getSpots() { return this.spots; }, any rogue piece of code elsewhere can call getSpots().clear() and wipe out your entire garage. Always return an unmodifiable copy.
  • 3. Tell, Don't Ask: Instead of asking an object for its internal numbers and doing math on its behalf, tell the object what you want it to do (e.g., spot.occupy(car) instead of if (spot.getStatus() == 0) { spot.setStatus(1); }).

Related Guides:

Final Thoughts

Low-Level Design is where engineering craftsmanship shows. It is not about using fancy patterns for the sake of looking clever; it is about building modular, readable, and thread-safe components that welcome change instead of breaking when the product requirements inevitably shift.

← PreviousDeep Dive into System Integration DesignNext →Security Design: Building Trust into Software