Low-level design: classes, methods and contracts
Open in the interactive app: practice, quiz, mock interview →In one minute
- Low-level design (LLD) decides what happens inside one component: which classes and interfaces exist, what state each one owns, how they relate, what each method promises, and which rules must always hold.
- High-level design picks the boxes and arrows between services; LLD fills in one box. Its unit of failure is a bug or a painful change, not an outage of a data centre.
- The core moves: model the entities and lifecycles, give each rule one owner, and put interfaces only where change is expected.
- A good LLD is correct (its public API cannot break an invariant), testable, and absorbs a likely change in few classes.
- Patterns and UML are tools for this, not the goal. The goal is code a teammate or a coding agent can extend without breaking the rules.
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
- Entities and value objects. Things with identity (
Member,Copy,Loan) and values compared by content (Isbn,Money). - Relationships. Who knows and owns whom, with multiplicities: a copy has at most one active loan.
- Responsibilities and interfaces. Which class decides each rule, and where behaviour varies behind an interface.
- State machines. The legal states of each stateful entity and the transitions between them.
- Contracts and errors. Each public method's preconditions, guarantees and failures.
- Concurrency and time. What happens if two members try to take the last copy at once, and where "now" comes from.
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:
- Split nouns that hide two concepts. "Book" means both the title and the physical copy. Merging them makes "three copies of one book" impossible to model.
- Promote relationships that carry data into classes. A loan is a link between a member and a copy, but it has a due date and a return date, so it becomes its own class rather than a field on either side.
- Drop nouns that are only attributes. "Fourteen days" is a configuration value, not a class.
- Put each verb where its data lives. "Borrow" needs the member's loan count, the member's overdue status and the copy's availability. No entity owns all three, so a
Librarycoordinates while each entity guards its own state: aCopyrefuses to be loaned twice.
Invariants first
Before drawing a class diagram, write down the rules that must always hold:
- A copy is in exactly one state: on the shelf, on loan to one member, or on the hold shelf for one member.
- A member never has more active loans than the limit.
- A member with an overdue loan gets no new loans.
- 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
- Requirements change more often than infrastructure, so LLD optimises for cheap, local change.
- Local change needs each rule to live in one place, so responsibilities are assigned by who owns the data a rule reads.
- Rules can still be bypassed, so state is encapsulated behind checking methods.
- Independently varying behaviour would spread
ifstatements, so it goes behind an interface at the cost of a negligible indirect call. - 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.idNotice 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
| Choice | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Rich domain model (entities enforce rules) | Rules live with data; hard to bypass | More classes; ORMs can fight it | Many rules, long-lived product |
| Anemic model plus services | Simple data classes; easy to persist | Rules scatter across services; easy to bypass | CRUD-heavy code with few rules |
| Enum plus transition table for state | Compact; all transitions visible | Per-state behaviour becomes switch | Few states, little per-state behaviour |
| State pattern (class per state) | Per-state behaviour is isolated | More classes and indirection | States with very different behaviour |
Pitfalls in production
- One class per noun, mechanically.
Book,BookManager,BookHelper, and no owner for any rule. Fix: give each invariant exactly one owning class. - Rules enforced only in the controller. A batch job or a second endpoint calls the repository directly and creates a double loan. Fix: put the check in the entity or in one service that every path goes through, and back it with a database constraint.
- Check-then-act races. "Is it available? Then lend it" in two steps lets two requests win. Fix: make check and change atomic with a lock, a conditional update, or a unique constraint on active loans per copy.
- Hidden time. Calling the system clock inside domain logic makes overdue rules untestable and flaky near midnight. Fix: inject a clock or pass
todayin. - Errors that leave partial state. The loan is recorded but the copy state update throws. Fix: validate everything first, then mutate, or wrap the mutation in a transaction.
Leadership lens
- Ask for the invariants list in every design review; it is the shortest document that reveals whether the author understood the domain.
- Record which rules the database also enforces: the domain model guards the common path, constraints guard every path, including 2 a.m. scripts.
- When coding agents implement features, a clear LLD (entities, invariants, interfaces) is the specification that keeps their output consistent across tasks; without one, each agent invents its own structure.
- Budget for the change you can name: if the business says fines will vary by member type next quarter, pay for the interface now; if nobody can name the change, do not.
Check yourself
Why is
Loanits own class rather than aborrowed_byfield onCopy?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.
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.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
- Introduction - Low Level Design (The Code Mate): how to go from software requirements to a low-level design.
- How to crack LLD (Low Level Design) Interview? (GeeksforGeeks): what interviewers expect in object-oriented design rounds.
Free resources
- Anemic Domain Model (Martin Fowler): why behaviour belongs with the data it guards.
- Design by contract (Wikipedia): preconditions, postconditions and invariants as a design discipline.
- Class-responsibility-collaboration card (Wikipedia): a lightweight technique for assigning responsibilities before drawing diagrams.
- Go Data Structures: Interfaces (Russ Cox): how Go interface values and itabs work at run time.
- Fast properties in V8 (V8 blog): hidden classes and what object shape means for JavaScript performance.