Book library LLD deep dive

Design a library system

Full interview walkthrough for a library management system: Book vs BookItem, catalog search, checkout/return with due dates and fines, reservation queues, and extensibility.

What this prompt is really asking

A library management system tracks a catalog of titles, physical copies on shelves, members who borrow them, due dates, late fines, and often a hold queue when every copy is out. Staff add copies; members search, checkout, and return.
You’ll hear: “Design a library management system.” The interview is less about Dewey decimals and more about whether you separate catalog identity from physical copies, and keep lending as an explicit transaction — not a boolean on the title.
01Clarify

Copies · limits · fines

02Model

Book ≠ BookItem

03Code

checkout / return

04Extend

holds · renew

Library lending loop
Search Book → checkout BookItem → return (+ fine) → notify reservation.

Analogy: catalog card vs shelf copy

Book vs copy
You search the card; you borrow a physical copy.

The catalog card (Book/ISBN) is not the thing in your backpack. The barcode sticker (BookItem) is. Holds wait on the card — “any copy of this title” — while loans attach to a specific sticker.

Clarifying questions → locked requirements

Structure clarifying questions around catalog shape, lending rules, errors, and extensions:
  • You: Multiple copies per title? They: Yes — search by title/ISBN; borrow a specific copy.
  • You: Loan limit and loan period? They: Cap concurrent loans per member; fixed due days (e.g. 14).
  • You: Late returns? They: Fine = days overdue × rate; block checkout if unpaid fines exceed a threshold (or keep fines simple and just record them).
  • You: Reservations when all copies are out? They: Nice-to-have — queue on the title; notify on return.
  • You: Persistence / multi-branch / payments UI? They: In-memory OO model; one library; no payment gateway.

Entities: Book vs BookItem (the core insight)

Library entities
Search Books; loan BookItems; reservations hang off the catalog Book.
If you model only Book with a copies: int and borrower_id, you cannot answer “which copy does Alice have?” or “put copy #3 on the repair rack.” Split identity from instance.
  • Book — catalog record: ISBN, title, authors. Owns a list of BookItems and (optionally) a reservation queue. Members search Books.
  • BookItem — one physical copy: barcode, rack/location, status (AVAILABLE / LOANED / RESERVED / LOST). Members borrow BookItems.
  • Member — id, name, active loan count / loan ids, outstanding fines, optional blocked flag.
  • Loan (or BookLending) — member + item + checkout date + due date + optional return date. The transaction record.
  • Catalog — indexes Books for search; not a second source of truth for stock (stock lives on BookItems).
  • LibraryService — orchestrates checkout/return/search; enforces limits and fine policy.

Class design from the requirements

Derive state from the locked rules. BookItem needs status. Loan needs dates. Member needs loan limit tracking. Service is the API surface.
from enum import Enum, auto
from datetime import date, timedelta
from typing import Optional
from collections import defaultdict, deque

class ItemStatus(Enum):
    AVAILABLE = auto()
    LOANED = auto()
    RESERVED = auto()
    LOST = auto()

class Book:
    def __init__(self, isbn: str, title: str, authors: list[str]):
        self.isbn, self.title, self.authors = isbn, title, authors
        self.items: list["BookItem"] = []
        self.hold_queue: deque[str] = deque()  # member ids

class BookItem:
    def __init__(self, barcode: str, book: Book, rack: str = ""):
        self.barcode, self.book, self.rack = barcode, book, rack
        self.status = ItemStatus.AVAILABLE

class Member:
    def __init__(self, member_id: str, name: str, loan_limit: int = 5):
        self.id, self.name = member_id, name
        self.loan_limit = loan_limit
        self.active_loans: dict[str, "Loan"] = {}  # barcode → Loan
        self.fines_owed: float = 0.0

    def can_borrow(self) -> bool:
        return len(self.active_loans) < self.loan_limit and self.fines_owed < 50.0

class Loan:
    def __init__(self, member: Member, item: BookItem,
                 checkout: date, due: date):
        self.member, self.item = member, item
        self.checkout, self.due = checkout, due
        self.returned: Optional[date] = None

class Catalog:
    def __init__(self):
        self.by_isbn: dict[str, Book] = {}
        self.by_title: dict[str, list[Book]] = defaultdict(list)
        self.by_author: dict[str, list[Book]] = defaultdict(list)

    def add_book(self, book: Book) -> None: ...
    def search(self, *, isbn=None, title=None, author=None) -> list[Book]: ...

class LibraryService:
    LOAN_DAYS = 14
    FINE_PER_DAY = 1.0

    def __init__(self):
        self.catalog = Catalog()
        self.members: dict[str, Member] = {}
        self.items: dict[str, BookItem] = {}  # barcode → item

    def checkout(self, member_id: str, barcode: str,
                 today: date) -> Optional[Loan]: ...
    def return_item(self, member_id: str, barcode: str,
                    today: date) -> float: ...  # fine charged
    def reserve(self, member_id: str, isbn: str) -> bool: ...
import java.time.LocalDate;
import java.util.*;

enum ItemStatus { AVAILABLE, LOANED, RESERVED, LOST }

class Book {
    String isbn, title;
    List<String> authors;
    List<BookItem> items = new ArrayList<>();
    Deque<String> holdQueue = new ArrayDeque<>(); // member ids

    Book(String isbn, String title, List<String> authors) {
        this.isbn = isbn; this.title = title; this.authors = authors;
    }
}

class BookItem {
    String barcode, rack;
    Book book;
    ItemStatus status = ItemStatus.AVAILABLE;

    BookItem(String barcode, Book book, String rack) {
        this.barcode = barcode; this.book = book; this.rack = rack == null ? "" : rack;
    }
}

class Member {
    String id, name;
    int loanLimit;
    Map<String, Loan> activeLoans = new HashMap<>(); // barcode → Loan
    double finesOwed = 0.0;

    Member(String memberId, String name, int loanLimit) {
        this.id = memberId; this.name = name; this.loanLimit = loanLimit;
    }

    boolean canBorrow() {
        return activeLoans.size() < loanLimit && finesOwed < 50.0;
    }
}

class Loan {
    Member member;
    BookItem item;
    LocalDate checkout, due;
    LocalDate returned; // nullable

    Loan(Member member, BookItem item, LocalDate checkout, LocalDate due) {
        this.member = member; this.item = item;
        this.checkout = checkout; this.due = due;
    }
}

class Catalog {
    Map<String, Book> byIsbn = new HashMap<>();
    Map<String, List<Book>> byTitle = new HashMap<>();
    Map<String, List<Book>> byAuthor = new HashMap<>();

    void addBook(Book book) { /* ... */ }
    List<Book> search(String isbn, String title, String author) { /* ... */ return List.of(); }
}

class LibraryService {
    static final int LOAN_DAYS = 14;
    static final double FINE_PER_DAY = 1.0;

    Catalog catalog = new Catalog();
    Map<String, Member> members = new HashMap<>();
    Map<String, BookItem> items = new HashMap<>(); // barcode → item

    Loan checkout(String memberId, String barcode, LocalDate today) { /* ... */ return null; }
    double returnItem(String memberId, String barcode, LocalDate today) { /* ... */ return 0; }
    boolean reserve(String memberId, String isbn) { /* ... */ return false; }
}

Implementation highlights

Interviewers usually want checkout and return_item in real code — status transitions, limits, and fine math.
def checkout(self, member_id: str, barcode: str,
              today: date) -> Optional[Loan]:
    member = self.members.get(member_id)
    item = self.items.get(barcode)
    if member is None or item is None:
        return None
    if not member.can_borrow():
        return None
    if item.status is ItemStatus.RESERVED:
        # only the head of the hold queue may take it
        if not item.book.hold_queue or item.book.hold_queue[0] != member_id:
            return None
    elif item.status is not ItemStatus.AVAILABLE:
        return None

    item.status = ItemStatus.LOANED
    due = today + timedelta(days=self.LOAN_DAYS)
    loan = Loan(member, item, today, due)
    member.active_loans[barcode] = loan
    if item.book.hold_queue and item.book.hold_queue[0] == member_id:
        item.book.hold_queue.popleft()
    return loan


def return_item(self, member_id: str, barcode: str, today: date) -> float:
    member = self.members.get(member_id)
    item = self.items.get(barcode)
    if member is None or barcode not in member.active_loans:
        raise ValueError("no active loan")
    loan = member.active_loans.pop(barcode)
    loan.returned = today

    days_late = max(0, (today - loan.due).days)
    fine = days_late * self.FINE_PER_DAY
    member.fines_owed += fine

    if item.book.hold_queue:
        item.status = ItemStatus.RESERVED
        # notify(item.book.hold_queue[0], item)  # Observer / callback
    else:
        item.status = ItemStatus.AVAILABLE
    return fine


def reserve(self, member_id: str, isbn: str) -> bool:
    book = self.catalog.by_isbn.get(isbn)
    if book is None or member_id not in self.members:
        return False
    if any(i.status is ItemStatus.AVAILABLE for i in book.items):
        return False  # go checkout instead
    if member_id in book.hold_queue:
        return False
    book.hold_queue.append(member_id)
    return True
Loan checkout(String memberId, String barcode, LocalDate today) {
    Member member = members.get(memberId);
    BookItem item = items.get(barcode);
    if (member == null || item == null) return null;
    if (!member.canBorrow()) return null;
    if (item.status == ItemStatus.RESERVED) {
        // only the head of the hold queue may take it
        if (item.book.holdQueue.isEmpty()
                || !item.book.holdQueue.peekFirst().equals(memberId)) {
            return null;
        }
    } else if (item.status != ItemStatus.AVAILABLE) {
        return null;
    }

    item.status = ItemStatus.LOANED;
    LocalDate due = today.plusDays(LOAN_DAYS);
    Loan loan = new Loan(member, item, today, due);
    member.activeLoans.put(barcode, loan);
    if (!item.book.holdQueue.isEmpty()
            && item.book.holdQueue.peekFirst().equals(memberId)) {
        item.book.holdQueue.pollFirst();
    }
    return loan;
}

double returnItem(String memberId, String barcode, LocalDate today) {
    Member member = members.get(memberId);
    BookItem item = items.get(barcode);
    if (member == null || !member.activeLoans.containsKey(barcode)) {
        throw new IllegalArgumentException("no active loan");
    }
    Loan loan = member.activeLoans.remove(barcode);
    loan.returned = today;

    long daysLate = Math.max(0, java.time.temporal.ChronoUnit.DAYS.between(loan.due, today));
    double fine = daysLate * FINE_PER_DAY;
    member.finesOwed += fine;

    if (!item.book.holdQueue.isEmpty()) {
        item.status = ItemStatus.RESERVED;
        // notify(item.book.holdQueue.peekFirst(), item);  // Observer / callback
    } else {
        item.status = ItemStatus.AVAILABLE;
    }
    return fine;
}

boolean reserve(String memberId, String isbn) {
    Book book = catalog.byIsbn.get(isbn);
    if (book == null || !members.containsKey(memberId)) return false;
    for (BookItem i : book.items) {
        if (i.status == ItemStatus.AVAILABLE) return false; // go checkout instead
    }
    if (book.holdQueue.contains(memberId)) return false;
    book.holdQueue.addLast(memberId);
    return true;
}

Verification trace

Narrate a short happy path + one failure:
  1. Catalog has Book ISBN-1 with items A (AVAILABLE) and B (AVAILABLE). Member M has 0 loans.
  2. checkout(M, A, day0) → Loan due day14; A=LOANED; M.active=1.
  3. checkout(M, B, …) until loan_limit → further checkout returns None.
  4. return_item(M, A, day20) → 6 days late → fine 6×rate; A=AVAILABLE (or RESERVED if queue non-empty).
  5. Another member reserved ISBN-1 while both copies were out → on return, status=RESERVED and notify head of queue.
Say the invariant out loud: count of LOANED items for a member equals len(active_loans); never checkout an item that isn’t AVAILABLE (or RESERVED for that member).

Extensibility follow-ups

  • Renew — extend loan.due if no one else is waiting on book.hold_queue and member isn’t over fine threshold.
  • Lost / damaged — ItemStatus.LOST; close loan; charge replacement fee via FinePolicy.
  • Observer notifications — ReservationNotifier.on_item_returned(book, item); LibraryService calls it after setting RESERVED — don’t wire SMTP in the domain.
  • Multi-branch — BookItem gains branch_id; search filters by branch; transfer between branches is inventory-move (see inventory).
  • Concurrency — lock per BookItem barcode on checkout/return; hold_queue mutations under the Book’s lock. Ordered locks if you ever touch two items in one transaction.

Common interview pitfalls

These mistakes show up constantly on this prompt. Name the trap, then show the fix in your design — don’t wait for the interviewer to catch you.

  • Collapsing Book (title) and BookItem (copy) into one class — checkout becomes wrong.
  • No due dates / fines model when interviewer asks “what happens if late?”
  • Reservation queue ignored when all copies are out.
  • Member borrowing limits not enforced.
  • Return that doesn’t clear the right BookItem state.
  • Searching catalog mutates inventory.

Interview script (say this)

Read this once out loud before a mock. It’s the spine of a strong answer — not a script to recite robotically.

  1. Clarify catalog vs copies, checkout/return, due dates, fines, reservations, member limits.
  2. Entities: Book (ISBN/metadata), BookItem (barcode, state), Member, Loan, ReservationQueue.
  3. API: checkout(item, member), return(item), reserve(book, member), calculate_fine.
  4. States on BookItem: AVAILABLE, LOANED, RESERVED, LOST.
  5. Trace: two members want last copy — loan vs reserve queue.
  6. Extensions: renewals, digital licenses as different LoanPolicy.

Extra verification traces

Walk these three traces on the board. If you can narrate them cleanly, your implementation section usually follows.

def checkout(self, item_id, member_id):
    item = self.items[item_id]
    with item.lock:
        member = self.members[member_id]
        if not member.can_borrow():
            raise RuntimeError("limit")
        if item.state != "AVAILABLE":
            raise RuntimeError("unavailable")
        item.state = "LOANED"
        return Loan(item_id, member_id, due=self.now()+self.loan_period)
Loan checkout(String itemId, String memberId) {
    BookItem item = items.get(itemId);
    synchronized (item.lock) {
        Member member = members.get(memberId);
        if (!member.canBorrow()) throw new RuntimeException("limit");
        if (!"AVAILABLE".equals(item.state)) throw new RuntimeException("unavailable");
        item.state = "LOANED";
        return new Loan(itemId, memberId, now().plus(loanPeriod));
    }
}

Staff-level follow-ups

At staff+, they twist the prompt. Answer in one sentence that names the seam — don’t redesign the whole board.

  • Inter-library loan? — ExternalSource port; BookItem may be VIRTUAL_IN_TRANSIT.
  • Fine rules change? — FinePolicy strategy by membership tier.
  • eBooks? — LicensePool with concurrent seat count instead of physical BookItem.
  • Hold shelf expiry? — RESERVED with TTL; expire job notifies next in queue.

Complete solution: checkout under lock

from datetime import date, timedelta
from enum import Enum, auto
from threading import Lock
from typing import Optional

class ItemStatus(Enum):
    AVAILABLE = auto()
    LOANED = auto()
    RESERVED = auto()

class BookItem:
    def __init__(self, barcode: str, isbn: str):
        self.barcode, self.isbn = barcode, isbn
        self.status = ItemStatus.AVAILABLE

class Member:
    def __init__(self, mid: str, limit: int = 5):
        self.id = mid
        self.limit = limit
        self.loans: dict[str, "Loan"] = {}
        self.fines = 0.0

class Loan:
    def __init__(self, member: Member, item: BookItem, due: date):
        self.member, self.item, self.due = member, item, due

class Library:
    LOAN_DAYS = 14
    FINE = 1.0

    def __init__(self):
        self.items: dict[str, BookItem] = {}
        self.members: dict[str, Member] = {}
        self._lock = Lock()

    def checkout(self, member_id: str, barcode: str, today: date) -> Optional[Loan]:
        with self._lock:
            m = self.members[member_id]
            it = self.items[barcode]
            if len(m.loans) >= m.limit or it.status is not ItemStatus.AVAILABLE:
                return None
            it.status = ItemStatus.LOANED
            loan = Loan(m, it, today + timedelta(days=self.LOAN_DAYS))
            m.loans[barcode] = loan
            return loan

    def return_item(self, member_id: str, barcode: str, today: date) -> float:
        with self._lock:
            m = self.members[member_id]
            loan = m.loans.pop(barcode, None)
            if loan is None:
                raise ValueError("no loan")
            fine = max(0, (today - loan.due).days) * self.FINE
            m.fines += fine
            loan.item.status = ItemStatus.AVAILABLE
            return fine
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

enum ItemStatus { AVAILABLE, LOANED, RESERVED }

class BookItem {
    String barcode, isbn;
    ItemStatus status = ItemStatus.AVAILABLE;
    BookItem(String barcode, String isbn) {
        this.barcode = barcode; this.isbn = isbn;
    }
}

class Member {
    String id;
    int limit;
    Map<String, Loan> loans = new HashMap<>();
    double fines = 0.0;
    Member(String mid, int limit) { this.id = mid; this.limit = limit; }
}

class Loan {
    Member member;
    BookItem item;
    LocalDate due;
    Loan(Member member, BookItem item, LocalDate due) {
        this.member = member; this.item = item; this.due = due;
    }
}

class Library {
    static final int LOAN_DAYS = 14;
    static final double FINE = 1.0;

    Map<String, BookItem> items = new HashMap<>();
    Map<String, Member> members = new HashMap<>();
    private final ReentrantLock lock = new ReentrantLock();

    Loan checkout(String memberId, String barcode, LocalDate today) {
        lock.lock();
        try {
            Member m = members.get(memberId);
            BookItem it = items.get(barcode);
            if (m.loans.size() >= m.limit || it.status != ItemStatus.AVAILABLE) {
                return null;
            }
            it.status = ItemStatus.LOANED;
            Loan loan = new Loan(m, it, today.plusDays(LOAN_DAYS));
            m.loans.put(barcode, loan);
            return loan;
        } finally {
            lock.unlock();
        }
    }

    double returnItem(String memberId, String barcode, LocalDate today) {
        lock.lock();
        try {
            Member m = members.get(memberId);
            Loan loan = m.loans.remove(barcode);
            if (loan == null) throw new IllegalArgumentException("no loan");
            long days = Math.max(0, ChronoUnit.DAYS.between(loan.due, today));
            double fine = days * FINE;
            m.fines += fine;
            loan.item.status = ItemStatus.AVAILABLE;
            return fine;
        } finally {
            lock.unlock();
        }
    }
}

Concurrency cases

  • Two members, one copy — checkout race; lock on Library (or per barcode).
  • Return vs checkout — serialize so AVAILABLE isn’t torn.
  • Hold queue notify — mutate status under lock; send email after.

← Lattice