Guide contents

Lesson 25 of 31

Design a Library Management System

The interview framework article ends with a small library: books with one copy each, members with a loan limit, lend and giveBack. Real libraries own several copies of a popular title, let members queue for a title that is out, and charge fines for late returns. This article extends that design without breaking it, and shows how much of the original survives: the IDs, the error style, the loan limit as a parameter, and the status type instead of a boolean.

Requirements

Functional requirements

  • The catalog holds titles (name, author). The library owns one or more physical copies of each title.
  • A member borrows a title, and the library hands out any available copy. A loan lasts 14 days.
  • A member may hold at most a fixed number of loans at once (the framework article's maxLoans).
  • If no copy is available, a member can join a hold queue for the title. The queue is first in, first out.
  • When a copy of a title with waiting holds is returned, it is reserved for the first member in the queue. Nobody else can borrow it until that member does.
  • A late return costs a fine, computed by a replaceable fine policy. A member who owes too much cannot borrow until paying.
  • Search finds titles by a fragment of the name or the author and reports how many copies are available.

Rules and assumptions

  • Dates are whole days from an injected value (Day), not read from the system clock, so results are reproducible.
  • Money is integer cents in a long.
  • Out of scope: renewals, reference-only books, multiple branches, notifications (an observer, see extensions).

What changes from the framework version

Framework version This design Why
BookId, one book, one copy TitleId and CopyId Many copies share one title. Holds and search are about titles. Loans and returns are about copies.
Status with Available and LentTo CopyStatus with Available, OnLoan and OnHold Two more facts to record: the due date, and the reservation.
LibraryError.AlreadyLent NoCopyAvailable(TitleId) The failure is now about the title.
LibraryError.NotLent NotOnLoan(CopyId) Same idea, new ID.
LimitReached(MemberId) unchanged The limit is still a parameter, maxLoans.
loansOf scans the books unchanged (scans the copies) Fine at this scale. An index is an extension.

The change from one ID to two is the important design step. "Book" was two concepts hidden in one word: the abstract work people search for and wait for, and the physical object that is on a shelf or in a bag.

Entities and relationships

classDiagram
    class Library {
        -titles: TreeMap~TitleId, Title~
        -copies: TreeMap~CopyId, Copy~
        -members: TreeMap~MemberId, Member~
        -holds: Map~TitleId, Deque~MemberId~~
        -policy: FinePolicy
        +checkout(member, title, today) CopyId
        +placeHold(member, title) int
        +returnCopy(copy, today) Returned
        +payFine(member, amount) Money
        +search(query) List~Hit~
    }
    class Title {
        <<record>>
        +name: String
        +author: String
    }
    class Copy {
        +title: TitleId
        +status: CopyStatus
    }
    class CopyStatus {
        <<sealed interface>>
        Available
        OnLoan(member, due)
        OnHold(member)
    }
    class Member {
        +name: String
        +fines: Money
    }
    class FinePolicy {
        <<interface>>
        +fine(daysLate) Money
    }
    Library *-- Title
    Library *-- Copy
    Library *-- Member
    Copy --> Title : TitleId
    Copy --> CopyStatus
    CopyStatus ..> Member : MemberId
    FinePolicy <|.. PerDay
    FinePolicy <|.. Grace
    Library o-- FinePolicy

The library owns titles, copies and members in maps keyed by record IDs. A copy refers to its title by TitleId, and a loan is a fact stored in the copy's status as OnLoan(member, due), so no reference cycles are needed. The holds queue is an ArrayDeque<MemberId> per title. The maps are TreeMaps so iteration follows ID order, which keeps every printed result the same on every run. See object graphs and references.

API

Java
public final class Library {
    public CopyId checkout(MemberId member, TitleId title, Day today);    // throws LibraryException
    public int placeHold(MemberId member, TitleId title);                 // throws LibraryException
    public Returned returnCopy(CopyId copy, Day today);                   // throws LibraryException
    public Money payFine(MemberId member, Money amount);                  // throws LibraryException
    public List<Hit> search(String query);
}

placeHold returns the member's position in the queue (1 is next). returnCopy returns a Returned value with the fine charged and the member, if any, whose hold is now ready, so the caller can send a notification. The library itself sends nothing. Search cannot fail and changes nothing, so it returns a list and throws nothing. Refusals are expected business outcomes, so they use the framework's style: an unchecked LibraryException that carries a sealed LibraryError.

Design decisions

Copy status is a sealed interface of records. OnLoan(member, due) carries exactly what a loan needs, and OnHold(member) carries the reservation. The illegal combination from the framework article's "Common mistakes", a copy that is not lent but has a borrower, remains unrepresentable. A switch over CopyStatus needs no default, so a new status forces every switch to be revisited.

One invariant ties holds to copies. If the queue for a title is not empty, no copy of that title is Available. It holds because placeHold is refused while a copy is available, and because returnCopy gives a returned copy to the head of the queue before it would ever become available. checkout first looks for a copy reserved for the requesting member, then for an available copy, so a queue jumper cannot take a reserved copy.

Fines are a strategy. How to price lateness varies between libraries, so FinePolicy takes the number of days late and returns Money. Two implementations: PerDay (a rate with a cap) and Grace, a decorator that ignores the first days and passes the rest to another policy. "No fines" is a one-line lambda. The same decorator idea is used for the capped pricing in the parking lot.

Fines block borrowing through a threshold. The library has a blockAt amount. A member whose unpaid fines reach it gets FinesOutstanding from checkout until payFine reduces the balance. Returns are never blocked, since a blocked return would keep the book out of circulation.

Dates are an argument. checkout and returnCopy receive today as a Day. There is no clock inside the library, so a test can walk through months in a few lines. The alternative, an injected clock interface as in the parking lot, is equally valid when many methods need the time. A Day is a plain count of days. It avoids time zones, which the library does not need.

Search is a query on the catalog. A case-insensitive substring match over name and author, returning results in ID order. A Hit reports available and total, which the interface needs to say "2 of 3 copies in".

Fines as a strategy

This program shows the policy interface with three implementations, including a decorator. FinePolicy has one method, so the "no fines" policy is a lambda.

Java
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;

record Money(long cents) implements Comparable<Money> {
    static final Money ZERO = new Money(0);

    Money {
        if (cents < 0) throw new IllegalArgumentException("negative money: " + cents);
    }

    Money min(Money other) { return cents <= other.cents ? this : other; }

    @Override public int compareTo(Money other) { return Long.compare(cents, other.cents); }
    @Override public String toString() { return "$%d.%02d".formatted(cents / 100, cents % 100); }
}

@FunctionalInterface
interface FinePolicy {
    Money fine(int daysLate);
}

record PerDay(long centsPerDay, Money cap) implements FinePolicy {
    @Override
    public Money fine(int daysLate) {
        return new Money(daysLate * centsPerDay).min(cap);
    }
}

/** Ignores the first {@code days} days, then delegates with the remaining lateness. */
record Grace(int days, FinePolicy inner) implements FinePolicy {
    @Override
    public Money fine(int daysLate) {
        return inner.fine(Math.max(0, daysLate - days));
    }
}

public class Main {
    public static void main(String[] args) {
        Map<String, FinePolicy> policies = new LinkedHashMap<>();
        policies.put("none", daysLate -> Money.ZERO);
        policies.put("25c/day cap $5", new PerDay(25, new Money(500)));
        policies.put("3 days grace", new Grace(3, new PerDay(25, new Money(500))));

        for (int days : new int[] {0, 3, 6, 40}) {
            String row = policies.entrySet().stream()
                    .map(e -> e.getKey() + ": " + e.getValue().fine(days))
                    .collect(Collectors.joining(" | "));
            System.out.printf("%2d days late | %s%n", days, row);
        }
    }
}
Output
 0 days late | none: $0.00 | 25c/day cap $5: $0.00 | 3 days grace: $0.00
 3 days late | none: $0.00 | 25c/day cap $5: $0.75 | 3 days grace: $0.00
 6 days late | none: $0.00 | 25c/day cap $5: $1.50 | 3 days grace: $0.75
40 days late | none: $0.00 | 25c/day cap $5: $5.00 | 3 days grace: $5.00

Grace takes any FinePolicy, so it combines with PerDay, with a lambda, or with another Grace. The library stores a FinePolicy field because the policy is chosen when the library is built. daysLate * centsPerDay multiplies an int by a long, so the product is computed as a long and cannot overflow for any realistic lateness.

Before the full program, this fragment shows the two-level catalog and the search that reports availability, so the data model can be checked on its own.

Java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

record TitleId(int value) implements Comparable<TitleId> {
    @Override public int compareTo(TitleId other) { return Integer.compare(value, other.value); }
}

record CopyId(int value) implements Comparable<CopyId> {
    @Override public int compareTo(CopyId other) { return Integer.compare(value, other.value); }
}

enum CopyState { AVAILABLE, ON_LOAN }

record Title(String name, String author) {}

record CopyRecord(TitleId title, CopyState state) {}

record Match(TitleId title, String name, long free, long total) {}

final class Catalog {
    final Map<TitleId, Title> titles = new TreeMap<>();
    final Map<CopyId, CopyRecord> copies = new TreeMap<>();

    List<Match> search(String query) {
        String q = query.toLowerCase();
        return titles.entrySet().stream()
                .filter(e -> e.getValue().name().toLowerCase().contains(q)
                        || e.getValue().author().toLowerCase().contains(q))
                .map(e -> {
                    List<CopyRecord> own = copies.values().stream()
                            .filter(c -> c.title().equals(e.getKey()))
                            .toList();
                    long free = own.stream().filter(c -> c.state() == CopyState.AVAILABLE).count();
                    return new Match(e.getKey(), e.getValue().name(), free, own.size());
                })
                .toList();
    }
}

public class Main {
    public static void main(String[] args) {
        var catalog = new Catalog();
        catalog.titles.put(new TitleId(1), new Title("Dune", "Frank Herbert"));
        catalog.titles.put(new TitleId(2), new Title("Emma", "Jane Austen"));
        catalog.copies.put(new CopyId(1), new CopyRecord(new TitleId(1), CopyState.ON_LOAN));
        catalog.copies.put(new CopyId(2), new CopyRecord(new TitleId(2), CopyState.AVAILABLE));
        catalog.copies.put(new CopyId(3), new CopyRecord(new TitleId(2), CopyState.ON_LOAN));

        for (String query : List.of("e", "AUSTEN", "herb", "zzz")) {
            System.out.println("\"" + query + "\": " + catalog.search(query));
        }
    }
}
Output
"e": [Match[title=TitleId[value=1], name=Dune, free=0, total=1], Match[title=TitleId[value=2], name=Emma, free=1, total=2]]
"AUSTEN": [Match[title=TitleId[value=2], name=Emma, free=1, total=2]]
"herb": [Match[title=TitleId[value=1], name=Dune, free=0, total=1]]
"zzz": []

The query "e" matches both titles, because it appears in "Dune" and "Emma". The result shows Dune with 0 of 1 copies free and Emma with 1 of 2. That count is what turns a search into something a member can act on: zero free copies is the cue to place a hold. The search scans every copy for every matching title, which is O(titles x copies). That is fine for a teaching-size catalog, and an index from title to copies removes the repeated scan.

Full implementation

The program below is the framework's library grown up. The scenario checks out a title with a single copy, builds a queue, returns the copy late, shows the reservation, charges a capped fine and blocks then unblocks a member. Types other than Main are top-level package-private types in the same file, as the checker needs for a one-file program.

Java
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.function.Predicate;
import java.util.function.Supplier;

// ---- values ----

record Money(long cents) implements Comparable<Money> {
    static final Money ZERO = new Money(0);

    Money {
        if (cents < 0) throw new IllegalArgumentException("negative money: " + cents);
    }

    Money plus(Money other) { return new Money(Math.addExact(cents, other.cents)); }
    Money minus(Money other) { return new Money(cents - other.cents); }
    Money min(Money other) { return cents <= other.cents ? this : other; }

    @Override public int compareTo(Money other) { return Long.compare(cents, other.cents); }
    @Override public String toString() { return "$%d.%02d".formatted(cents / 100, cents % 100); }
}

record Day(int value) {
    Day plusDays(int days) { return new Day(value + days); }
    int daysSince(Day earlier) { return value - earlier.value; }
}

record TitleId(int value) implements Comparable<TitleId> {
    @Override public int compareTo(TitleId other) { return Integer.compare(value, other.value); }
}

record CopyId(int value) implements Comparable<CopyId> {
    @Override public int compareTo(CopyId other) { return Integer.compare(value, other.value); }
}

record MemberId(int value) implements Comparable<MemberId> {
    @Override public int compareTo(MemberId other) { return Integer.compare(value, other.value); }
}

// ---- errors ----

sealed interface LibraryError {
    record UnknownTitle(TitleId title) implements LibraryError {}
    record UnknownMember(MemberId member) implements LibraryError {}
    record UnknownCopy(CopyId copy) implements LibraryError {}
    record LimitReached(MemberId member) implements LibraryError {}
    record FinesOutstanding(Money owed) implements LibraryError {}
    record NoCopyAvailable(TitleId title) implements LibraryError {}
    record CopiesAvailable(TitleId title) implements LibraryError {}
    record AlreadyHolding(MemberId member, TitleId title) implements LibraryError {}
    record NotOnLoan(CopyId copy) implements LibraryError {}
    record Overpayment(Money owed) implements LibraryError {}
}

final class LibraryException extends RuntimeException {
    private final LibraryError error;

    LibraryException(LibraryError error) {
        super(error.toString());
        this.error = error;
    }

    LibraryError error() { return error; }
}

// ---- fines ----

@FunctionalInterface
interface FinePolicy {
    Money fine(int daysLate);
}

record PerDay(long centsPerDay, Money cap) implements FinePolicy {
    @Override
    public Money fine(int daysLate) {
        return new Money(daysLate * centsPerDay).min(cap);
    }
}

// ---- catalog data ----

record Title(String name, String author) {}

sealed interface CopyStatus {
    record Available() implements CopyStatus {}
    record OnLoan(MemberId member, Day due) implements CopyStatus {}
    record OnHold(MemberId member) implements CopyStatus {}
}

final class Copy {
    private final TitleId title;
    private CopyStatus status = new CopyStatus.Available();

    Copy(TitleId title) { this.title = title; }

    TitleId title() { return title; }
    CopyStatus status() { return status; }
    void status(CopyStatus next) { status = next; }
}

final class Member {
    private final String name;
    private Money fines = Money.ZERO;

    Member(String name) { this.name = name; }

    String name() { return name; }
    Money fines() { return fines; }
    void fines(Money next) { fines = next; }
}

/** holdReadyFor is null when nobody was waiting. Callers use {@link #readyHold()}. */
record Returned(Money fine, MemberId holdReadyFor) {
    Optional<MemberId> readyHold() { return Optional.ofNullable(holdReadyFor); }
}

record Hit(TitleId title, String name, long available, long total) {}

final class Library {
    private final Map<TitleId, Title> titles = new TreeMap<>();
    private final Map<CopyId, Copy> copies = new TreeMap<>();
    private final Map<MemberId, Member> members = new TreeMap<>();
    private final Map<TitleId, Deque<MemberId>> holds = new TreeMap<>();
    private final FinePolicy policy;
    private final int maxLoans;
    private final int loanDays;
    private final Money blockAt;
    private int nextCopy = 1;

    Library(FinePolicy policy, int maxLoans, int loanDays, Money blockAt) {
        this.policy = policy;
        this.maxLoans = maxLoans;
        this.loanDays = loanDays;
        this.blockAt = blockAt;
    }

    TitleId addTitle(int id, String name, String author, int copyCount) {
        var titleId = new TitleId(id);
        titles.put(titleId, new Title(name, author));
        for (int i = 0; i < copyCount; i++) copies.put(new CopyId(nextCopy++), new Copy(titleId));
        return titleId;
    }

    MemberId addMember(int id, String name) {
        var memberId = new MemberId(id);
        members.put(memberId, new Member(name));
        return memberId;
    }

    String memberName(MemberId id) { return members.get(id).name(); }
    Money finesOf(MemberId id) { return members.get(id).fines(); }
    String authorOf(TitleId id) { return titles.get(id).author(); }

    int loansOf(MemberId member) {
        int count = 0;
        for (Copy copy : copies.values()) {
            if (copy.status() instanceof CopyStatus.OnLoan loan && loan.member().equals(member)) count++;
        }
        return count;
    }

    private Optional<CopyId> findCopy(TitleId title, Predicate<CopyStatus> wanted) {
        return copies.entrySet().stream()
                .filter(e -> e.getValue().title().equals(title) && wanted.test(e.getValue().status()))
                .map(Map.Entry::getKey)
                .findFirst();
    }

    CopyId checkout(MemberId member, TitleId title, Day today) {
        Member account = members.get(member);
        if (account == null) throw new LibraryException(new LibraryError.UnknownMember(member));
        if (!titles.containsKey(title)) throw new LibraryException(new LibraryError.UnknownTitle(title));
        Money owed = account.fines();
        if (owed.compareTo(blockAt) >= 0) throw new LibraryException(new LibraryError.FinesOutstanding(owed));
        if (loansOf(member) >= maxLoans) throw new LibraryException(new LibraryError.LimitReached(member));

        // A copy reserved for this member comes first, then any free copy.
        CopyId copyId = findCopy(title, status -> status.equals(new CopyStatus.OnHold(member)))
                .or(() -> findCopy(title, status -> status instanceof CopyStatus.Available))
                .orElseThrow(() -> new LibraryException(new LibraryError.NoCopyAvailable(title)));
        copies.get(copyId).status(new CopyStatus.OnLoan(member, today.plusDays(loanDays)));
        return copyId;
    }

    int placeHold(MemberId member, TitleId title) {
        if (!members.containsKey(member)) throw new LibraryException(new LibraryError.UnknownMember(member));
        if (!titles.containsKey(title)) throw new LibraryException(new LibraryError.UnknownTitle(title));
        if (findCopy(title, status -> status instanceof CopyStatus.Available).isPresent()) {
            throw new LibraryException(new LibraryError.CopiesAvailable(title));
        }
        Deque<MemberId> queue = holds.computeIfAbsent(title, t -> new ArrayDeque<>());
        if (queue.contains(member)) throw new LibraryException(new LibraryError.AlreadyHolding(member, title));
        queue.addLast(member);
        return queue.size();
    }

    Returned returnCopy(CopyId copyId, Day today) {
        Copy copy = copies.get(copyId);
        if (copy == null) throw new LibraryException(new LibraryError.UnknownCopy(copyId));
        if (!(copy.status() instanceof CopyStatus.OnLoan(MemberId member, Day due))) {
            throw new LibraryException(new LibraryError.NotOnLoan(copyId));
        }
        Money fine = policy.fine(Math.max(0, today.daysSince(due)));
        Deque<MemberId> queue = holds.get(copy.title());
        MemberId next = queue == null ? null : queue.pollFirst();
        copy.status(next == null ? new CopyStatus.Available() : new CopyStatus.OnHold(next));
        Member account = members.get(member);
        account.fines(account.fines().plus(fine));
        return new Returned(fine, next);
    }

    Money payFine(MemberId member, Money amount) {
        Member account = members.get(member);
        if (account == null) throw new LibraryException(new LibraryError.UnknownMember(member));
        if (amount.compareTo(account.fines()) > 0) {
            throw new LibraryException(new LibraryError.Overpayment(account.fines()));
        }
        account.fines(account.fines().minus(amount));
        return account.fines();
    }

    List<Hit> search(String query) {
        String q = query.toLowerCase();
        return titles.entrySet().stream()
                .filter(e -> e.getValue().name().toLowerCase().contains(q)
                        || e.getValue().author().toLowerCase().contains(q))
                .map(e -> {
                    List<Copy> own = copies.values().stream()
                            .filter(copy -> copy.title().equals(e.getKey()))
                            .toList();
                    long available = own.stream().filter(c -> c.status() instanceof CopyStatus.Available).count();
                    return new Hit(e.getKey(), e.getValue().name(), available, own.size());
                })
                .toList();
    }
}

public class Main {
    static void show(String label, Supplier<?> action) {
        try {
            System.out.println(label + ": " + action.get());
        } catch (LibraryException e) {
            System.out.println(label + ": refused " + e.error());
        }
    }

    public static void main(String[] args) {
        var lib = new Library(new PerDay(25, new Money(500)), 2, 14, new Money(500));
        TitleId dune = lib.addTitle(1, "Dune", "Frank Herbert", 1);
        TitleId emma = lib.addTitle(2, "Emma", "Jane Austen", 2);
        TitleId ulysses = lib.addTitle(3, "Ulysses", "James Joyce", 1);
        MemberId ana = lib.addMember(1, "Ana");
        MemberId ben = lib.addMember(2, "Ben");
        MemberId cy = lib.addMember(3, "Cy");

        CopyId duneCopy = lib.checkout(ana, dune, new Day(0));
        show("ben checkout dune", () -> lib.checkout(ben, dune, new Day(0)));
        show("ben hold dune", () -> lib.placeHold(ben, dune));
        show("cy hold dune", () -> lib.placeHold(cy, dune));
        show("ben hold again", () -> lib.placeHold(ben, dune));
        show("hold on emma", () -> lib.placeHold(ben, emma));

        CopyId emmaCopy = lib.checkout(ana, emma, new Day(1));
        show("ana third loan", () -> lib.checkout(ana, ulysses, new Day(1)));
        show("search 'du'", () -> lib.search("du"));

        // Dune is due on day 14. Ana returns it on day 20.
        show("return dune", () -> lib.returnCopy(duneCopy, new Day(20)));
        show("cy checkout dune", () -> lib.checkout(cy, dune, new Day(20)));
        show("ben checkout dune", () -> lib.checkout(ben, dune, new Day(20)));
        show("return dune again", () -> lib.returnCopy(duneCopy, new Day(21)).fine());

        // Emma is due on day 15. Returned on day 100, the fine hits the cap.
        show("return emma", () -> lib.returnCopy(emmaCopy, new Day(100)));
        show("ana blocked", () -> lib.checkout(ana, ulysses, new Day(100)));
        show("overpay", () -> lib.payFine(ana, new Money(9_999)));
        System.out.println("ana pays $6.50, owes " + lib.payFine(ana, new Money(650)));
        show("ana checkout ulysses", () -> lib.checkout(ana, ulysses, new Day(100)));
        var availability = lib.search("").stream()
                .map(h -> "(" + h.name() + ", " + h.available() + ", " + h.total() + ")")
                .toList();
        System.out.println("available now: " + availability);
        System.out.println(lib.memberName(ana) + " owes " + lib.finesOf(ana));
        System.out.println(lib.authorOf(emma));
    }
}
Output
ben checkout dune: refused NoCopyAvailable[title=TitleId[value=1]]
ben hold dune: 1
cy hold dune: 2
ben hold again: refused AlreadyHolding[member=MemberId[value=2], title=TitleId[value=1]]
hold on emma: refused CopiesAvailable[title=TitleId[value=2]]
ana third loan: refused LimitReached[member=MemberId[value=1]]
search 'du': [Hit[title=TitleId[value=1], name=Dune, available=0, total=1]]
return dune: Returned[fine=$1.50, holdReadyFor=MemberId[value=2]]
cy checkout dune: refused NoCopyAvailable[title=TitleId[value=1]]
ben checkout dune: CopyId[value=1]
return dune again: $0.00
return emma: Returned[fine=$5.00, holdReadyFor=null]
ana blocked: refused FinesOutstanding[owed=$6.50]
overpay: refused Overpayment[owed=$6.50]
ana pays $6.50, owes $0.00
ana checkout ulysses: CopyId[value=4]
available now: [(Dune, 0, 1), (Emma, 2, 2), (Ulysses, 0, 1)]
Ana owes $0.00
Jane Austen

Walk through the trace against the design. Ben's checkout fails with NoCopyAvailable and he joins the queue at position 1, Cy at 2. A hold on Emma is refused because copies are free, which keeps the invariant. Ana's third loan is refused by maxLoans, the framework's original limit. Returning Dune 6 days late costs $1.50 and the copy becomes OnHold for Ben, so Cy's attempt to borrow it fails even though nobody is reading it. Ben's checkout then finds the copy reserved for him. Emma's return, 85 days late, shows the cap: the fine is $5.00, Ana's total reaches $6.50 which passes the $5.00 block threshold, and checkout refuses her until she pays.

Two details of the Java form. returnCopy uses a record pattern in instanceof to test the status and bind member and due in one step. The negated test throws, so the bound names are in scope for the rest of the method. And Returned.holdReadyFor is null when nobody is waiting: Optional is meant for return values, not for fields, so the record keeps a plain reference and offers readyHold() as the Optional view.

Extensions

Follow-up Where the change goes
Notify a member when a hold is ready returnCopy already reports holdReadyFor. For decoupling, publish an event to observers. See event bus and observers.
Hold expires if not collected in 3 days OnHold(MemberId member, Day until). A tick(today) method moves expired copies to the next member or to Available.
Renewals renew(copy, today) valid only if the title has no queue and the copy is not overdue. It replaces the status with a new OnLoan that has a later due.
Reference-only or non-lendable items A lendable flag on the title, or a CopyStatus.Reference record that checkout never selects.
Different rules per member type (student, staff) A MemberKind enum carrying limits and loan days, replacing the single maxLoans and loanDays.
Multiple branches Add BranchId to Copy, and let checkout prefer a local copy, with a transfer state as another CopyStatus record.
Fast loansOf for many copies A Map<MemberId, Set<CopyId>> maintained in checkout and returnCopy, kept private so both stay in step.
Simultaneous users Make the public methods synchronized, or use one ReentrantLock. Then split the state into per-title locks if profiling shows contention. See thread-safe components.
Persist to a database Replace the maps with a repository interface. See testability and dependency injection.

A follow-up worth preparing: what happens if two people return copies of the same title at the same time? Under one lock, the returns are serialized, the first reserves for the head of the queue and the second for the next member. Without a lock, both could poll the same member, or one ArrayDeque update could corrupt the other, since ArrayDeque is not thread-safe. That is the argument for keeping the queue and the copy status in one structure with one owner.

Summary

  • Split "book" into a title (what people search and wait for) and a copy (what is borrowed), each with its own record ID.
  • Record the state of a copy in a sealed interface of records: Available, OnLoan(member, due), OnHold(member). Impossible combinations cannot be built.
  • Keep an invariant that ties holds to copies: a non-empty queue means no copy is available, enforced by refusing a hold while a copy is free and by giving returned copies to the queue head first.
  • Compute fines with a FinePolicy interface. A cap and a grace period are parameters or decorators, not new code paths in the library.
  • Pass dates in as Day values so every scenario is reproducible.
  • Keep the framework's habits: an unchecked exception that carries a sealed error type, private state behind methods, and limits such as maxLoans as constructor parameters.