Command Palette

Search for a command to run...

PHASE 16Advanced Java 17+ ~45 min· topic 4 of 4

Topic 16.4

Capstone: A Library Management System

In one line

Build a small library system step by step: enums for fixed rules, records for data, a sealed interface for results, collections for state, Optional for lookups, exceptions for errors, java.time for due dates and fines, and streams for reports, ending in one runnable file you can extend.

Think of it like this

A school library has a shelf of books, a list of members, and a notebook where the librarian writes who borrowed what and when it's due. When you bring a book back late, the librarian counts the days and charges a small fine. That is the whole system you'll build: books, members, loans, rules, and reports, written in modern Java.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

ISBN
International Standard Book Number: a unique code printed on every published book, used here as the book's ID.
Catalog
The list of all titles the library owns, looked up by ISBN.
Copy
One physical book. A title can have several copies, and each can be lent to a different member.
Loan
A record that one member borrowed one book on a date and must return it by a due date.
Fine
The amount charged for each day a book is returned after its due date.
Result type
A return value that describes an outcome, success or failure, instead of throwing an exception for an expected failure.
Compact constructor
A record constructor without a parameter list, used to validate or normalise the components before the fields are set.
Report
A summary computed from the data, such as all overdue loans or how often each title was borrowed.

Step by step

01Step 1: requirements and the class sketch

Nouns in the requirements become types: book, member, member type, loan, library. Verbs become methods: add copies, find, borrow, give back, list overdue, count borrows. Rules (limits, loan length, fine rate) need a home: the limits and loan length belong to the member type, the fine rate to the library.

Draw it before coding. This is the same model as the System Design course's library LLD problem (/topic/phase-4/library-management), here kept to what one Java file needs.

Step 1: requirements and the class sketchdiagram
Rendering diagram…

02Step 2: enums that carry rules

MemberType is a fixed set, so it's an enum. Instead of if (type == STUDENT) limit = 2 scattered around, each constant carries its own data through a constructor: STUDENT(2, 14). Adding a member type later is one new line, and every rule for it is in one place.

Genre is a plain enum. Enums are also ideal map keys: EnumMap stores them in an array indexed by ordinal(), which is compact and iterates in declaration order.

Main.javawhole filejava
enum MemberType {
    STUDENT(2, 14), STAFF(5, 28);

    final int maxLoans, loanDays;

    MemberType(int maxLoans, int loanDays) {
        this.maxLoans = maxLoans;
        this.loanDays = loanDays;
    }
}

03Step 3: records with validation

record Book(String isbn, String title, String author, Genre genre, int year) gives you the constructor, accessors, equals, hashCode and toString in one line. A compact constructor validates the inputs, so an invalid Book can never exist anywhere in the program: "make illegal states unrepresentable".

Loan is also a record, with a small method daysLate(returned). Records can have methods; they just can't have extra mutable fields. Because Member is a record, two Member objects with the same id, name and type are equals, which is what the loan lookups rely on.

Main.javawhole filejava
record Book(String isbn, String title, String author, Genre genre, int year) {
    Book {
        Objects.requireNonNull(isbn, "isbn");
        if (title == null || title.isBlank()) throw new IllegalArgumentException("title is blank");
    }
}

record Loan(Book book, Member member, LocalDate borrowed, LocalDate due) {
    long daysLate(LocalDate returned) {
        return Math.max(0, ChronoUnit.DAYS.between(due, returned));
    }
}

04Step 4: the Library's state and its collections

Choose each collection for its job (Topic 9.13). The catalog is a LinkedHashMap<String, Book>: O(1) lookup by ISBN, and iteration in the order books were added, so listings are stable. Available copies is a HashMap<String, Integer> updated with merge(isbn, -1, Integer::sum), which reads, adds and writes in one call. Active loans and history are ArrayLists: small, appended to, scanned by streams.

All four fields are private final: the references never change and no outside code can reach them. Methods are the only way in, so the rules can't be skipped.

Main.javawhole filejava
class Library {
    static final int FINE_PER_DAY = 10;
    private final Map<String, Book> catalog = new LinkedHashMap<>();
    private final Map<String, Integer> available = new HashMap<>();
    private final List<Loan> active = new ArrayList<>();
    private final List<Loan> history = new ArrayList<>();

    void addCopies(Book book, int count) {
        catalog.putIfAbsent(book.isbn(), book);
        available.merge(book.isbn(), count, Integer::sum);
    }
}

05Step 5: Optional for lookups, exceptions for mistakes

find(isbn) returns Optional<Book>: the type itself says the book may be missing, and callers choose what to do with map, orElse or orElseThrow. Inside borrow, a missing book is a caller error, so find(isbn).orElseThrow(() -> new BookNotFoundException(isbn)) turns the empty case into a clear, specific exception.

BookNotFoundException extends RuntimeException: unchecked, because the caller can't sensibly recover from passing a bad ISBN except by fixing the code or the input (Topic 7.3). Its message includes the ISBN, so the log line alone explains the failure (Topic 7.8).

Main.javawhole filejava
Optional<Book> find(String isbn) {
    return Optional.ofNullable(catalog.get(isbn));
}

// caller decides what "missing" means
String title = lib.find("444").map(Book::title).orElse("none");

06Step 6: borrowing returns a sealed result

borrow can succeed or be refused for a business reason. Model that as sealed interface BorrowResult permits Borrowed, Rejected, with each case a record carrying its data. The compiler now knows the complete list of outcomes. In Java 17 the caller uses instanceof patterns; in Java 21 a switch over a sealed type is checked for exhaustiveness, so adding a Waitlisted case later makes every switch that forgets it fail to compile.

The rule checks run in a fixed order: book exists (else exception), member under their limit, a copy available. Only when every check passes does the method change state: decrement copies, create the Loan, add it to active and history. Checking everything before mutating anything means a refused borrow leaves the library unchanged.

Main.javawhole filejava
BorrowResult borrow(Member m, String isbn, LocalDate today) {
    Book book = find(isbn).orElseThrow(() -> new BookNotFoundException(isbn));
    long held = active.stream().filter(l -> l.member().equals(m)).count();
    if (held >= m.type().maxLoans) return new Rejected(m.name() + " already has " + held + " books");
    if (available.get(isbn) == 0) return new Rejected("no copies of " + book.title() + " left");
    available.merge(isbn, -1, Integer::sum);           // all checks passed: now change state
    Loan loan = new Loan(book, m, today, today.plusDays(m.type().loanDays));
    active.add(loan);
    history.add(loan);
    return new Borrowed(loan);
}
Step 6: borrowing returns a sealed resultdiagram
Rendering diagram…

07Step 7: returns and fines with java.time

giveBack finds the member's active loan for that ISBN with a stream and findFirst(), throws IllegalStateException if there is none, removes it, puts the copy back, and returns the fine: daysLate * FINE_PER_DAY. ChronoUnit.DAYS.between(due, returned) counts calendar days correctly across month ends and leap years; Math.max(0, …) turns early returns into zero.

The method takes today as a parameter instead of calling LocalDate.now(). That makes every result reproducible: tests and this page can pass fixed dates. In a real service you'd inject a java.time.Clock and call LocalDate.now(clock), so tests can use Clock.fixed(...) (Topic 12.5).

08Step 8: reports with streams

Reports are read-only questions over the data, which is exactly what streams are for. Overdue loans: active.stream().filter(l -> l.due().isBefore(today)).sorted(Comparator.comparing(Loan::due)).toList(). Borrow counts per title: history.stream().collect(groupingBy(l -> l.book().title(), TreeMap::new, counting())), where the TreeMap makes the output sorted and deterministic.

Return unmodifiable results (toList() returns one) so callers can't change the library's data through a report.

09Step 9: run it, then grow it

The final program below ties the steps together and runs a fixed scenario: two members, three titles, a refused borrow, a limit hit, a missing ISBN, an overdue report, a late return with a fine and a final report. Predict each line before you run it.

Then extend it with the practice tasks. Each extension tests a different idea: a reservation queue (collections), a new member type and a fine cap (enums), a top-N report (comparators), and safe concurrent borrowing (Phase 13). For a real service you'd also add persistence (a repository interface, /topic/phase-2/repository-unit-of-work) and unit tests with JUnit 5 (Topic 15.5).

Try it yourself

  1. 1

    Predict the scenario

    Before running "The complete library, one file", write down all 13 output lines. The tricky ones: Ravi's first Cosmos due date (borrowed on 3 March, staff loans last 28 days), the order of the overdue list, and Asha's fine.

  2. 2

    Change a rule in one place

    Change STUDENT(2, 14) to STUDENT(3, 7) and run again. Asha's SPQR borrow now succeeds, all her due dates move to 8 March, and her Dune fine grows to 120. One line changed every rule for students, because the rule lives in the enum.

  3. 3

    Return a book that was never borrowed

    Add lib.giveBack(asha, "333", mar20); at the end of main. Predict the exception and its message, then run it. You should see java.lang.IllegalStateException: Asha has not borrowed 333.

Code & diagrams

Step 1-3: the model on its own Java 16+ New tab

String.isBlank is Java 11 and records are Java 16. The blank title never becomes a Book: the compact constructor throws before the object exists.

Sign in to run this example in your browser.

Expected output

Book[isbn=111, title=Dune, author=Herbert, genre=FICTION, year=1965]
same book? true
rejected: title is blank
STUDENT may borrow 2 books for 14 days
STAFF may borrow 5 books for 28 days
Step 8: catalog reports with streams Java 16+ New tab

EnumMap iterates in the enum's declaration order and TreeMap in sorted key order, so every report line is the same on every run.

Sign in to run this example in your browser.

Expected output

by genre:  {FICTION=[Dune, Children of Dune, Contact], SCIENCE=[Cosmos, The Gene], HISTORY=[SPQR]}
oldest:    Dune (1965)
2+ books:  [Herbert, Sagan]
'dune':    [Children of Dune, Dune]
years:     1965-2016, 6 books
The complete library, one file Java 17+ New tab

Both of Asha's loans are due on the same day; Stream.sorted is stable for ordered streams, so they keep the order in which they were borrowed. Asha returns Dune 5 days late: 5 x 10 = 50.

Sign in to run this example in your browser.

Expected output

borrowed Dune until 2026-03-15
rejected: no copies of Dune left
borrowed Cosmos until 2026-03-15
rejected: Asha already has 2 books
borrowed Cosmos until 2026-03-31
error: no book with ISBN 999
overdue on 2026-03-20: [Asha/Dune, Asha/Cosmos]
Asha returns Dune, fine 50
borrowed Dune until 2026-04-17
Ravi returns Cosmos, fine 0
borrow counts: {Cosmos=2, Dune=2}
find 333: SPQR
find 444: none
Java 21: an exhaustive switch over the result Java 21+java

Record patterns (Java 21) take the record apart in the case label. If you add a third permitted subtype, this switch stops compiling until you handle it.

static String describe(BorrowResult r) {
    return switch (r) {                                   // no default needed: the type is sealed
        case Borrowed(Loan loan) -> "borrowed " + loan.book().title() + " until " + loan.due();
        case Rejected(String reason) -> "rejected: " + reason;
    };
}
A unit test for the borrow rules (JUnit 5)java

Passing the date in makes these tests deterministic. Topic 15.5 covers JUnit 5 in full.

import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;

class LibraryTest {
    private final LocalDate day = LocalDate.of(2026, 3, 1);
    private final Member student = new Member("m1", "Asha", MemberType.STUDENT);

    @Test
    void studentCannotExceedLimit() {
        Library lib = new Library();
        lib.addCopies(new Book("1", "A", "x", Genre.FICTION, 2000), 1);
        lib.addCopies(new Book("2", "B", "x", Genre.FICTION, 2000), 1);
        lib.addCopies(new Book("3", "C", "x", Genre.FICTION, 2000), 1);
        assertInstanceOf(Borrowed.class, lib.borrow(student, "1", day));
        assertInstanceOf(Borrowed.class, lib.borrow(student, "2", day));
        assertInstanceOf(Rejected.class, lib.borrow(student, "3", day));
    }

    @Test
    void unknownIsbnThrows() {
        assertThrows(BookNotFoundException.class, () -> new Library().borrow(student, "404", day));
    }
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Add an outcome the sealed interface doesn't permit

Add record Waitlisted(int position) implements BorrowResult {} without changing the permits clause.

terminal
$ javac Main.java
── what you'll see ──
Main.java:31: error: class is not allowed to extend sealed class: BorrowResult (as it is not listed in its 'permits' clause)
record Waitlisted(int position) implements BorrowResult {}
^
1 error

Break #2

Forget a case in a Java 21 switch

Write switch (r) { case Borrowed b -> System.out.println("borrowed"); } as a statement with no Rejected case.

terminal
$ javac --release 21 Main.java
── what you'll see ──
Main.java:88: error: the switch statement does not cover all possible input values
switch (r) {
^
1 error

Myth vs fact

Myth

Every failure should be an exception.

Fact

Expected business outcomes, such as a member at their limit, are normal results. Returning a sealed result type keeps them visible in the method's signature and checked by the compiler; exceptions are for mistakes and broken environments.

Myth

Records are only for DTOs that move data over the network.

Fact

Records are for any immutable data with value semantics: domain values like Book and Loan, map keys, results and events.

Myth

A design is better when it uses every Java feature.

Fact

Each feature here has a job. If a feature doesn't solve a problem you've named, leave it out.

Interview problem

The problem

Extend the library for several branches

"The city has five library branches. A member can borrow from any branch and return to any branch. Members should be able to see which branch has a copy. How do you change your design?"

You're given

  • Copies belong to a branch and move when returned elsewhere.
  • Member limits apply across all branches.
  • Availability lookup by title must stay fast.

The interviewer follows up

01

Would you still use a sealed BorrowResult?

02

Where would the fine rules live now?

When it breaks

Two requests borrow the last copy at the same moment.

What you see

Both see one copy available and both succeed: the count goes to -1, two members hold loans for one physical book, and later returns make the count wrong forever.

Fix & prevent

Make check-and-update atomic: synchronize the borrow, lock per ISBN, or in a database use a conditional update in a transaction. Add an invariant check (available >= 0) that fails loudly in tests.

Fines are computed with LocalDate.now() on a server in a different time zone.

What you see

A book returned at 11 pm local time counts as a day later on a UTC server, and members are fined for a day they weren't late. Tests pass or fail depending on when they run.

Fix & prevent

Inject a Clock set to the library's zone (Clock.system(ZoneId.of("Asia/Kolkata"))) and compute dates with LocalDate.now(clock); use Clock.fixed in tests (Topic 12.5).

The history list grows forever in memory.

What you see

Every loan ever made stays in an ArrayList; after years of use the heap fills, GC pauses grow, and the service eventually fails with OutOfMemoryError.

Fix & prevent

Persist history to a database and keep only active loans in memory; compute reports with queries, or keep pre-aggregated counters instead of raw history.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The Library class mixes state and rules. In a larger service, split it: a LoanPolicy per member type (Strategy, /topic/phase-2/strategy), a BookRepository and LoanRepository for persistence (/topic/phase-2/repository-unit-of-work), and a LibraryService that coordinates them in a transaction.

  • ▸

    active.stream().filter(...) scans every active loan on each borrow, which is O(n). That's fine for a school library; at scale, index loans by member with Map<Member, List<Loan>> so the limit check is O(1), and keep the indexes consistent inside the same method.

  • ▸

    available.get(isbn) == 0 compares an Integer with an int, so it unboxes and compares numerically, which is correct. available.get(isbn) == available.get(other) would compare references and fail above 127 (Topic 16.3).

  • ▸

    Fines are long integer units, never double, so there are no rounding errors (Topic 1.4). Real money also needs a currency; BigDecimal or a Money value type holding minor units and a currency code is the usual production choice.

Remember this

  1. 1

    The point of a capstone is to choose tools, not just use them. Each Java feature in this program is there because it fits a job: an enum for a fixed set of member types with their rules (Topic 4.10), a record for each piece of immutable data (Topic 4.9), a sealed interface for the closed set of outcomes of a borrow (Topic 5.11), a map for lookup by ISBN (Topic 9.4), Optional for "maybe there's no such book" (Topic 10.7), exceptions for programming errors (Phase 7), LocalDate for dates (Topic 12.5), and streams for reports (Phase 10).

  2. 2

    Start from requirements, as in an LLD interview (Topic 16.2). Members are students or staff; a student may hold 2 books for 14 days, staff 5 books for 28 days. A book can have several copies. Borrowing fails if the member is at their limit or no copy is left. Returning late costs 10 per day. Librarians want reports: overdue loans and how often each title was borrowed.

  3. 3

    Separate expected outcomes from errors. A member hitting their limit is a normal business result, so borrow returns a Rejected value the caller must handle. Asking for an ISBN that doesn't exist, or returning a book you never borrowed, is a mistake by the calling code, so it throws an exception. This split keeps exceptions rare and makes the normal path type-checked.

  4. 4

    Keep state private and data immutable. Book, Member and Loan are records that never change; the Library class owns the only mutable state (the catalog, copy counts and loans) behind methods that enforce the rules. Nobody outside can make the count of available copies go negative, which is encapsulation doing its job.

  5. 5

    Build it in small runnable steps: the model first, then lookup, then borrow, then return and fines, then reports. Run after each step. The final version below is one file of about 120 lines that compiles on Java 17 and runs in the browser; the practice tasks extend it with reservations, a new member type, a top-N report and thread safety.

Explain it without notes

01

Why are Book, Member and Loan records, while Library is a normal class?

02

Why does borrow return a sealed result for a refused borrow but throw an exception for an unknown ISBN?

03

Why does the program pass dates in instead of calling LocalDate.now()?

04

How would you make this library safe for many threads borrowing at once?

05

Which collections did you choose for the catalog, copy counts and loans, and why?

Practice

01

Extension 1, reservations: when no copy is left, put the member in a first-come, first-served queue for that ISBN; on return, hand the copy to the first member waiting instead of putting it back on the shelf.

02

Extension 2, rules: add a GUEST member type (1 book for 7 days) and cap any single fine at 200. Print the due date for each member type and the fine for 0, 3 and 30 days late.

03

Extension 3, report: return the top N most borrowed titles from a borrow history, highest count first, ties broken alphabetically.

04

Extension 4, concurrency: 100 tasks on an 8-thread pool all try to borrow the last copy of one book. Make sure exactly one succeeds.

Trade-offs

  • ↔

    Result types vs exceptions: sealed results make expected outcomes explicit and compiler-checked but add types; exceptions are lighter for rare errors but invisible in the signature if unchecked.

  • ↔

    One Library class vs many services: a single class is easy to read and test at this size; splitting into policies, repositories and services pays off once rules, storage and teams grow.

  • ↔

    In-memory collections vs a database: maps and lists make the logic clear and fast, but lose data on restart and need explicit locking; a database adds durability and transactions at the cost of setup and latency.

Done when you can

  • Done when you can build the complete library from a blank file without looking, step by step.

  • Done when you can explain why each feature (enum, record, sealed interface, Optional, exception, stream) was chosen for its job.

  • Done when you can predict every line of the final program's output.

  • Done when you have implemented at least two of the four extensions and run them.

  • Done when you can explain how to make borrowing thread-safe and how to test it with fixed dates.