InterviewKata
Low-level design · Problem P09 of 15 · Classic systems

Library management

The trap in this problem is the first noun: a library has books, but you lend copies. Separating the title from its physical copies, and giving each copy a status, makes loans, holds and fines fall into place.

1. The prompt

The prompt"Design a library system. Members borrow and return books, with a limit on how many they can hold. Late returns are fined. Members can reserve a book that's out."

2. Clarify the requirements

Spend the first five minutes here. Ask, then write the answers down where the interviewer can see them.

AskAssume (unless told otherwise)
How long is a loan?14 days.
Fine?25 cents per day late.
How many loans per member?Two.
When a reserved book comes back?It's held for the first member waiting, and nobody else can borrow it.

Must do

  • Borrow a copy of a title if one is free
  • Return it; fine if late
  • Waitlist: a returned copy is held for the next member in line
  • Limit loans per member

Must be

  • No copy lent to two members
  • Fines and loan rules easy to change
  • Fair: first in the waitlist, first served

Out of scope

  • Search and catalogue
  • Renewals (a follow-up)
  • Payments for fines

3. The classes

The class diagram: arrows show who uses or holds whom.
ClassIts one job
LibraryBorrow and return, applying the rules
BookCopyOne physical copy and its status: available, on loan, on hold
MemberTheir loans and limit
LoanWhich copy, which member, when it's due
FinePolicyHow much a late return costs
WaitlistA queue of members per title

4. How it works

5. Patterns and why

PatternWhereWhy it's worth it here
Title vs instanceBook (isbn, title) vs BookCopy (id, status)You lend copies; a title can have many
State as an enumCopyStatus: AVAILABLE, ON_LOAN, ON_HOLDCheckout rules read the status, not a tangle of flags
StrategyFinePolicyStudents and staff can have different fines without editing Library
Memory hook: "Lend copies, queue for titles"Status lives on each copy; the waitlist is per title, and a returned copy is held for the head of the queue.

6. The code

Days are plain numbers to keep the demo exact. The member ids and copy ids are strings.

from __future__ import annotations
from collections import deque
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional


class CopyStatus(Enum):
    AVAILABLE = auto()
    ON_LOAN = auto()
    ON_HOLD = auto()


@dataclass
class Copy:
    id: str
    isbn: str
    status: CopyStatus = CopyStatus.AVAILABLE
    held_for: Optional[str] = None


@dataclass
class Member:
    id: str
    max_loans: int = 2
    loans: set[str] = field(default_factory=set)   # copy ids


@dataclass(frozen=True)
class Loan:
    copy_id: str
    member_id: str
    due_day: int


class FinePolicy:
    def __init__(self, cents_per_day: int = 25) -> None:
        self.rate = cents_per_day

    def fine(self, due_day: int, returned_day: int) -> int:
        return max(0, returned_day - due_day) * self.rate


class Library:
    LOAN_DAYS = 14

    def __init__(self, fines: FinePolicy) -> None:
        self.fines = fines
        self.titles: dict[str, str] = {}
        self.copies: dict[str, Copy] = {}
        self.members: dict[str, Member] = {}
        self.loans: dict[str, Loan] = {}                    # copy id -> loan
        self.waitlist: dict[str, deque[str]] = {}           # isbn -> member ids

    def add_book(self, isbn: str, title: str, copy_ids: list[str]) -> None:
        self.titles[isbn] = title
        for cid in copy_ids:
            self.copies[cid] = Copy(cid, isbn)

    def join(self, member_id: str) -> None:
        self.members[member_id] = Member(member_id)

    def checkout(self, member_id: str, isbn: str, today: int) -> str:
        m, title = self.members[member_id], self.titles[isbn]
        if len(m.loans) >= m.max_loans:
            return f"{member_id}: loan limit reached"
        copy = next((c for c in self.copies.values() if c.isbn == isbn and
                     (c.status is CopyStatus.AVAILABLE or (c.status is CopyStatus.ON_HOLD and c.held_for == member_id))), None)
        if copy is None:
            q = self.waitlist.setdefault(isbn, deque())
            if member_id not in q:
                q.append(member_id)
            return f"{member_id}: no copy of {title} free, added to waitlist (position {list(q).index(member_id) + 1})"
        copy.status, copy.held_for = CopyStatus.ON_LOAN, None
        m.loans.add(copy.id)
        self.loans[copy.id] = Loan(copy.id, member_id, today + self.LOAN_DAYS)
        return f"{member_id}: borrowed {title} ({copy.id}), due day {today + self.LOAN_DAYS}"

    def give_back(self, copy_id: str, today: int) -> str:
        loan = self.loans.pop(copy_id)
        self.members[loan.member_id].loans.discard(copy_id)
        copy = self.copies[copy_id]
        fine = self.fines.fine(loan.due_day, today)
        msg = f"{loan.member_id}: returned {copy_id}" + (f", fine ${fine // 100}.{fine % 100:02d}" if fine else "")
        q = self.waitlist.get(copy.isbn)
        if q:
            copy.status, copy.held_for = CopyStatus.ON_HOLD, q.popleft()
            msg += f"; held for {copy.held_for}"
        else:
            copy.status = CopyStatus.AVAILABLE
        return msg


if __name__ == "__main__":
    lib = Library(FinePolicy(25))
    lib.add_book("978-1449373320", "Designing Data-Intensive Applications", ["DDIA-1"])
    lib.add_book("978-0262510875", "SICP", ["SICP-1", "SICP-2"])
    for m in ("ana", "ben", "cara"):
        lib.join(m)
    print(lib.checkout("ana", "978-1449373320", 1))
    print(lib.checkout("ben", "978-1449373320", 2))
    print(lib.checkout("ana", "978-0262510875", 3))
    print(lib.checkout("ana", "978-0262510875", 3))
    print(lib.give_back("DDIA-1", 18))
    print(lib.checkout("cara", "978-1449373320", 18))
    print(lib.checkout("ben", "978-1449373320", 19))
enum CopyStatus { AVAILABLE, ON_LOAN, ON_HOLD }

class BookCopy {
  status = CopyStatus.AVAILABLE;
  heldFor: string | null = null;
  constructor(readonly id: string, readonly isbn: string) {}
}

class Member {
  readonly loans = new Set<string>(); // copy ids
  constructor(readonly id: string, readonly maxLoans = 2) {}
}

interface Loan { copyId: string; memberId: string; dueDay: number; }

class FinePolicy {
  constructor(private centsPerDay = 25) {}
  fine(dueDay: number, returnedDay: number): number { return Math.max(0, returnedDay - dueDay) * this.centsPerDay; }
}

class Library {
  static readonly LOAN_DAYS = 14;
  private titles = new Map<string, string>();
  private copies = new Map<string, BookCopy>();
  private members = new Map<string, Member>();
  private loans = new Map<string, Loan>();         // copy id -> loan
  private waitlist = new Map<string, string[]>();  // isbn -> member ids

  constructor(private fines: FinePolicy) {}

  addBook(isbn: string, title: string, copyIds: string[]): void {
    this.titles.set(isbn, title);
    for (const id of copyIds) this.copies.set(id, new BookCopy(id, isbn));
  }

  join(memberId: string): void { this.members.set(memberId, new Member(memberId)); }

  checkout(memberId: string, isbn: string, today: number): string {
    const m = this.members.get(memberId)!, title = this.titles.get(isbn)!;
    if (m.loans.size >= m.maxLoans) return `${memberId}: loan limit reached`;
    const copy = [...this.copies.values()].find((c) => c.isbn === isbn &&
      (c.status === CopyStatus.AVAILABLE || (c.status === CopyStatus.ON_HOLD && c.heldFor === memberId)));
    if (!copy) {
      const q = this.waitlist.get(isbn) ?? [];
      if (!q.includes(memberId)) q.push(memberId);
      this.waitlist.set(isbn, q);
      return `${memberId}: no copy of ${title} free, added to waitlist (position ${q.indexOf(memberId) + 1})`;
    }
    copy.status = CopyStatus.ON_LOAN;
    copy.heldFor = null;
    m.loans.add(copy.id);
    const due = today + Library.LOAN_DAYS;
    this.loans.set(copy.id, { copyId: copy.id, memberId, dueDay: due });
    return `${memberId}: borrowed ${title} (${copy.id}), due day ${due}`;
  }

  giveBack(copyId: string, today: number): string {
    const loan = this.loans.get(copyId)!;
    this.loans.delete(copyId);
    this.members.get(loan.memberId)!.loans.delete(copyId);
    const copy = this.copies.get(copyId)!;
    const fine = this.fines.fine(loan.dueDay, today);
    let msg = `${loan.memberId}: returned ${copyId}` + (fine ? `, fine $${Math.floor(fine / 100)}.${String(fine % 100).padStart(2, "0")}` : "");
    const q = this.waitlist.get(copy.isbn);
    if (q && q.length) {
      copy.status = CopyStatus.ON_HOLD;
      copy.heldFor = q.shift()!;
      msg += `; held for ${copy.heldFor}`;
    } else copy.status = CopyStatus.AVAILABLE;
    return msg;
  }
}

const lib = new Library(new FinePolicy(25));
lib.addBook("978-1449373320", "Designing Data-Intensive Applications", ["DDIA-1"]);
lib.addBook("978-0262510875", "SICP", ["SICP-1", "SICP-2"]);
for (const m of ["ana", "ben", "cara"]) lib.join(m);
console.log(lib.checkout("ana", "978-1449373320", 1));
console.log(lib.checkout("ben", "978-1449373320", 2));
console.log(lib.checkout("ana", "978-0262510875", 3));
console.log(lib.checkout("ana", "978-0262510875", 3));
console.log(lib.giveBack("DDIA-1", 18));
console.log(lib.checkout("cara", "978-1449373320", 18));
console.log(lib.checkout("ben", "978-1449373320", 19));
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

enum CopyStatus { AVAILABLE, ON_LOAN, ON_HOLD }

class BookCopy {
    final String id, isbn;
    CopyStatus status = CopyStatus.AVAILABLE;
    String heldFor;

    BookCopy(String id, String isbn) {
        this.id = id;
        this.isbn = isbn;
    }
}

class Member {
    final String id;
    final int maxLoans;
    final Set<String> loans = new HashSet<>(); // copy ids

    Member(String id, int maxLoans) {
        this.id = id;
        this.maxLoans = maxLoans;
    }
}

record Loan(String copyId, String memberId, int dueDay) {}

class FinePolicy {
    private final int centsPerDay;

    FinePolicy(int centsPerDay) {
        this.centsPerDay = centsPerDay;
    }

    int fine(int dueDay, int returnedDay) {
        return Math.max(0, returnedDay - dueDay) * centsPerDay;
    }
}

class Library {
    static final int LOAN_DAYS = 14;
    private final FinePolicy fines;
    private final Map<String, String> titles = new HashMap<>();
    private final Map<String, BookCopy> copies = new LinkedHashMap<>();
    private final Map<String, Member> members = new HashMap<>();
    private final Map<String, Loan> loans = new HashMap<>();             // copy id -> loan
    private final Map<String, Deque<String>> waitlist = new HashMap<>(); // isbn -> member ids

    Library(FinePolicy fines) {
        this.fines = fines;
    }

    void addBook(String isbn, String title, List<String> copyIds) {
        titles.put(isbn, title);
        copyIds.forEach(id -> copies.put(id, new BookCopy(id, isbn)));
    }

    void join(String memberId) {
        members.put(memberId, new Member(memberId, 2));
    }

    synchronized String checkout(String memberId, String isbn, int today) {
        Member m = members.get(memberId);
        String title = titles.get(isbn);
        if (m.loans.size() >= m.maxLoans) return memberId + ": loan limit reached";
        BookCopy copy = copies.values().stream().filter(c -> c.isbn.equals(isbn) && (c.status == CopyStatus.AVAILABLE
                || (c.status == CopyStatus.ON_HOLD && memberId.equals(c.heldFor)))).findFirst().orElse(null);
        if (copy == null) {
            Deque<String> q = waitlist.computeIfAbsent(isbn, k -> new ArrayDeque<>());
            if (!q.contains(memberId)) q.addLast(memberId);
            return memberId + ": no copy of " + title + " free, added to waitlist (position " + (List.copyOf(q).indexOf(memberId) + 1) + ")";
        }
        copy.status = CopyStatus.ON_LOAN;
        copy.heldFor = null;
        m.loans.add(copy.id);
        loans.put(copy.id, new Loan(copy.id, memberId, today + LOAN_DAYS));
        return memberId + ": borrowed " + title + " (" + copy.id + "), due day " + (today + LOAN_DAYS);
    }

    synchronized String giveBack(String copyId, int today) {
        Loan loan = loans.remove(copyId);
        members.get(loan.memberId()).loans.remove(copyId);
        BookCopy copy = copies.get(copyId);
        int fine = fines.fine(loan.dueDay(), today);
        StringBuilder msg = new StringBuilder(loan.memberId() + ": returned " + copyId);
        if (fine > 0) msg.append(", fine $").append(fine / 100).append(".").append(fine % 100 < 10 ? "0" : "").append(fine % 100);
        Deque<String> q = waitlist.get(copy.isbn);
        if (q != null && !q.isEmpty()) {
            copy.status = CopyStatus.ON_HOLD;
            copy.heldFor = q.pollFirst();
            msg.append("; held for ").append(copy.heldFor);
        } else copy.status = CopyStatus.AVAILABLE;
        return msg.toString();
    }
}

public class Main {
    public static void main(String[] args) {
        Library lib = new Library(new FinePolicy(25));
        lib.addBook("978-1449373320", "Designing Data-Intensive Applications", List.of("DDIA-1"));
        lib.addBook("978-0262510875", "SICP", List.of("SICP-1", "SICP-2"));
        for (String m : List.of("ana", "ben", "cara")) lib.join(m);
        System.out.println(lib.checkout("ana", "978-1449373320", 1));
        System.out.println(lib.checkout("ben", "978-1449373320", 2));
        System.out.println(lib.checkout("ana", "978-0262510875", 3));
        System.out.println(lib.checkout("ana", "978-0262510875", 3));
        System.out.println(lib.giveBack("DDIA-1", 18));
        System.out.println(lib.checkout("cara", "978-1449373320", 18));
        System.out.println(lib.checkout("ben", "978-1449373320", 19));
    }
}

Output (the same in all three languages)

ana: borrowed Designing Data-Intensive Applications (DDIA-1), due day 15
ben: no copy of Designing Data-Intensive Applications free, added to waitlist (position 1)
ana: borrowed SICP (SICP-1), due day 17
ana: loan limit reached
ana: returned DDIA-1, fine $0.75; held for ben
cara: no copy of Designing Data-Intensive Applications free, added to waitlist (position 1)
ben: borrowed Designing Data-Intensive Applications (DDIA-1), due day 33

7. Concurrency and edge cases

8. Follow-up questions

What interviewers ask once the core works, and a short answer for each.

How would you add renewals?
Allowed if nobody is on the waitlist for that title and the renewal limit isn't reached; push the due day out.
How would you send due-date reminders?
A daily job finds loans due tomorrow and publishes events; a notifier subscribes (observer).
Search by author or title?
A separate catalogue/search component with an index; keep it out of the lending rules.

9. Common mistakes

One Book class with an available flagThen a title can't have several copies, or each copy duplicates the title data.
Putting a returned reserved book back on the shelfSomeone else can grab it before the person who waited.
Fines computed in five placesPut the rule in one policy object.

10. Check yourself

Why separate Book from BookCopy?

Status belongs to a copy; title data belongs to the book.

A reserved book is returned. What happens?

Fairness for the people who waited.

Your turn

  1. Add renewals (10 min). Only when nobody is waiting.
  2. Expire uncollected holds (10 min). After three days, pass the copy to the next member.