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.
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.
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.
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.
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).
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.
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);
}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
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
Change a rule in one place
Change
STUDENT(2, 14)toSTUDENT(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
Return a book that was never borrowed
Add
lib.giveBack(asha, "333", mar20);at the end ofmain. Predict the exception and its message, then run it. You should seejava.lang.IllegalStateException: Asha has not borrowed 333.
Code & diagrams
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.
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 daysEnumMap iterates in the enum's declaration order and TreeMap in sorted key order, so every report line is the same on every run.
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 booksBoth 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.
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: noneRecord 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;
};
}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.
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.
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
Would you still use a sealed BorrowResult?
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
Libraryclass mixes state and rules. In a larger service, split it: aLoanPolicyper member type (Strategy,/topic/phase-2/strategy), aBookRepositoryandLoanRepositoryfor persistence (/topic/phase-2/repository-unit-of-work), and aLibraryServicethat 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 withMap<Member, List<Loan>>so the limit check is O(1), and keep the indexes consistent inside the same method. - ▸
available.get(isbn) == 0compares anIntegerwith anint, 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
longinteger units, neverdouble, so there are no rounding errors (Topic 1.4). Real money also needs a currency;BigDecimalor aMoneyvalue type holding minor units and a currency code is the usual production choice.
Remember this
- 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),
Optionalfor "maybe there's no such book" (Topic 10.7), exceptions for programming errors (Phase 7),LocalDatefor dates (Topic 12.5), and streams for reports (Phase 10). - 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
Separate expected outcomes from errors. A member hitting their limit is a normal business result, so
borrowreturns aRejectedvalue 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
Keep state private and data immutable.
Book,MemberandLoanare records that never change; theLibraryclass 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
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
Why are Book, Member and Loan records, while Library is a normal class?
Why does borrow return a sealed result for a refused borrow but throw an exception for an unknown ISBN?
Why does the program pass dates in instead of calling LocalDate.now()?
How would you make this library safe for many threads borrowing at once?
Which collections did you choose for the catalog, copy counts and loans, and why?
Practice
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.
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.
Extension 3, report: return the top N most borrowed titles from a borrow history, highest count first, ties broken alphabetically.
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.