Engineering Atlas › Low-Level Design

Low-level design: classes, methods and contracts

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Where LLD sits

Picture a library system. The high-level design says there is a catalogue service, a loans service backed by Postgres, and a notification service. It covers scale and data flow, but not what a loan is.

The low-level design of the loans service does. It says a Title is the abstract book (one ISBN) and a Copy is a physical object with a barcode; a Member can hold at most five Loans; a Loan links one copy to one member with a due date; a Hold queues a member for a title; and returning a copy with a waiting hold puts it on the hold shelf instead of the open shelf. It also says which object enforces each rule and how checkout fails.

Below LLD is the code itself. The boundary is fuzzy: LLD is whatever detail you need to agree on before writing code so that the code is easy to write correctly.

What an LLD contains

From requirements to classes

A classic first pass: nouns in the requirements are candidate classes and verbs are candidate methods. "A member can borrow up to five copies of titles; a loan is due after fourteen days; a member with an overdue loan cannot borrow; members can place holds on titles with no copy available."

That gives Member, Copy, Title, Loan, Hold, borrow and placeHold. Then judgement takes over:

Invariants first

Before drawing a class diagram, write down the rules that must always hold:

  1. A copy is in exactly one state: on the shelf, on loan to one member, or on the hold shelf for one member.
  2. A member never has more active loans than the limit.
  3. A member with an overdue loan gets no new loans.
  4. Holds for a title are served in the order they were placed.

Then make the checking methods the only way to change that state; that is what encapsulation is for. Invariants also show where locks go: rule 1 is a per-copy rule, so a per-copy lock or a conditional UPDATE ... WHERE state = 'shelf' is enough; rule 2 spans a member's loans, so the check and the insert must be atomic per member.

Where patterns come in

Patterns enter when a requirement is expected to vary. If fines differ by member type and the business changes them every year, a FinePolicy interface with one class per policy (Strategy) is worth it. If members must be notified when a held copy arrives, an event the notifier subscribes to (Observer) keeps the loan code from knowing about email. If the copy's lifecycle grows to include repair, lost and in-transit states, an explicit state machine (State, or an enum with a transition table) stops if chains from multiplying. Without such pressure, a plain method is better.

The rest of this course

How to Learn LLD? lays out the full path. The OOP lessons (Classes and Objects, Enums, Interfaces, Encapsulation, Abstraction, Inheritance, Polymorphism) give the building blocks; the relationship lessons (Association, Aggregation, Composition, Dependency, Realization) make "who owns whom" precise; the principles (Coupling and Cohesion, SOLID, DRY, KISS, YAGNI, Law of Demeter) judge a split; Class Diagram, Sequence Diagram and State Machine Diagram communicate it; patterns are reusable moves; and Design Parking Lot, Design Vending Machine, Design Movie Booking System and the other design problems put it all together.

The same shape elsewhere

The library's hardest rule, one copy to at most one borrower, is the contention problem shape: many actors competing for one unit. It is one seat per ticket in Design Movie Booking System and one spot per car in Design Parking Lot. The answer is always the same: one object owns the unit's state, and every change is an atomic check-and-change through it.

Down the stack

Layer 1The contract

The contract of an LLD is the set of method contracts plus the invariants. For checkout(member, isbn, today): the precondition is that both exist; on success it returns a copy id, that copy is on loan to that member with due = today + loan_days, and the member's loan count went up by one; on failure it raises a specific error (LimitReached, HasOverdue, Unavailable) and changes nothing. "Changes nothing on failure" is the part most designs forget, and it is testable.

Layer 2The mechanism

Languages give you different tools to enforce that contract. Private fields and constructors stop outside code from creating a Loan without a due date. Enums or sum types make illegal states unrepresentable: a copy's state is Shelf | OnLoan(member, due) | Held(member), so "on loan with no member" cannot be expressed. Interfaces let Library call policy.fine(loan, today) without knowing which policy it has; the call is resolved at run time through a vtable in C++ or Java, an itab in Go, or a prototype chain lookup in JavaScript. Injecting the clock keeps time out of the rules, which is why the practice task passes today in.

Layer 3The machine

Each object is an allocation. A Java object has a 12 to 16 byte header; a CPython instance has a 16-byte header plus its attribute storage; a Go struct has none, and a slice of structs is one contiguous block. Ten million copies as small objects fit in about 1 GB of managed heap, and an interface call costs a few nanoseconds, so at library scale clarity wins. What does matter is concurrency: two requests for the last copy on two threads must not both see "available". An uncontended mutex costs around 20 to 50 ns; a contended one can park a thread for microseconds; and once state lives in a database, the equivalent is a row lock or a conditional update, each costing a round trip of a fraction of a millisecond.

Why it's built this way

  1. Requirements change more often than infrastructure, so LLD optimises for cheap, local change.
  2. Local change needs each rule to live in one place, so responsibilities are assigned by who owns the data a rule reads.
  3. Rules can still be bypassed, so state is encapsulated behind checking methods.
  4. Independently varying behaviour would spread if statements, so it goes behind an interface at the cost of a negligible indirect call.
  5. Several threads or servers may run the same method at once, so the check and change of shared state must be one atomic step, which shapes the design as much as any pattern does.

Diagram

stateDiagram-v2
  [*] --> Shelf: add copy, no holds waiting
  [*] --> Held: add copy, hold waiting
  Shelf --> OnLoan: checkout
  OnLoan --> Shelf: return, no holds
  OnLoan --> Held: return, hold waiting
  Held --> OnLoan: checkout by holder

Worked example

The coordinating method checks everything before changing anything.

def checkout(self, member_id, isbn, today):
    member = self._member(member_id)            # raises if unknown
    if len(member.loans) >= self.max_loans:
        raise LimitReached(member_id)
    if any(loan.due < today for loan in member.loans):
        raise HasOverdue(member_id)
    copy = self._pick_copy(isbn, member_id)      # held for this member, else on shelf
    if copy is None:
        raise Unavailable(isbn)
    loan = Loan(copy.id, member_id, due=today + self.loan_days)
    copy.lend(loan)                              # Copy enforces its own state machine
    member.loans.append(loan)
    return copy.id

Notice the split. Library checks the rules that span entities (limits, overdue loans, choosing a copy). Copy.lend checks the copy's own state. If a later requirement adds a "lost" state, only Copy changes. If fines are added, a FinePolicy is called from return_copy, and checkout does not change. Under concurrency, one lock per branch is plenty for a few hundred checkouts a minute; beyond that, lock per member and per copy in a fixed order.

Trade-offs

ChoiceStrengthsWeaknessesChoose when
Rich domain model (entities enforce rules)Rules live with data; hard to bypassMore classes; ORMs can fight itMany rules, long-lived product
Anemic model plus servicesSimple data classes; easy to persistRules scatter across services; easy to bypassCRUD-heavy code with few rules
Enum plus transition table for stateCompact; all transitions visiblePer-state behaviour becomes switchFew states, little per-state behaviour
State pattern (class per state)Per-state behaviour is isolatedMore classes and indirectionStates with very different behaviour

Pitfalls in production

Leadership lens

Check yourself

  1. Why is Loan its own class rather than a borrowed_by field on Copy?

    Show answer

    A loan carries its own data (due date, return date, fines) and history; a field on the copy loses past loans and has nowhere to keep that data.

  2. Which class should refuse a second loan of the same copy, and why there?

    Show answer

    Copy, because the rule reads only the copy's state; putting it there means every path that lends a copy is checked, not just the one service that remembered to.

  3. What makes "check availability, then create the loan" unsafe, and what are two fixes?

    Show answer

    Two concurrent requests can both pass the check before either writes. Make it atomic with a lock around check and change, or with a conditional update or unique constraint in the database.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →