Project: A Library System
Time to combine the whole course into one program: a small library system that manages books and members, handles loans and returns with business rules, calculates late fees, and produces reports. It uses records, enums, sealed types, collections, streams, exceptions and java.time. Read it, run it, then extend it with the challenges at the end.
The design
| Type | Role | Lessons |
|---|---|---|
Book, Member (records) | Immutable data with validation | 32 |
Genre, MemberType (enums) | Fixed categories with behaviour | 33 |
Loan | A loan with due date and fee calculation | 25, 50 |
LoanResult (sealed) | The possible outcomes of borrowing | 49 |
LibraryException | Domain errors | 37 |
Library | The service holding state and rules | 26, 40–42 |
Main | Runs a scenario and prints reports | 46–47 |
The complete program
JAVA Main.java
import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.TreeMap; import java.util.stream.Collectors; enum Genre { FICTION, SCIENCE, HISTORY, CHILDREN } enum MemberType { STANDARD(3, 14), STUDENT(5, 21), STAFF(10, 28); final int maxLoans; final int loanDays; MemberType(int maxLoans, int loanDays) { this.maxLoans = maxLoans; this.loanDays = loanDays; } } record Book(String isbn, String title, String author, Genre genre) { Book { if (isbn == null || !isbn.matches("\\d{13}")) throw new IllegalArgumentException("ISBN must be 13 digits: " + isbn); } } record Member(int id, String name, MemberType type) {} class Loan { static final double FEE_PER_DAY = 0.25; static final double MAX_FEE = 10.0; final Book book; final Member member; final LocalDate borrowed; final LocalDate due; private LocalDate returned; Loan(Book book, Member member, LocalDate borrowed) { this.book = book; this.member = member; this.borrowed = borrowed; this.due = borrowed.plusDays(member.type().loanDays); } boolean isOpen() { return returned == null; } void close(LocalDate date) { returned = date; } double feeOn(LocalDate date) { LocalDate end = returned != null ? returned : date; long late = Math.max(0, ChronoUnit.DAYS.between(due, end)); return Math.min(MAX_FEE, late * FEE_PER_DAY); } } sealed interface LoanResult permits Borrowed, Refused {} record Borrowed(String title, LocalDate due) implements LoanResult {} record Refused(String reason) implements LoanResult {} class LibraryException extends RuntimeException { LibraryException(String message) { super(message); } } class Library { private final Map<String, Book> books = new HashMap<>(); private final Map<Integer, Member> members = new HashMap<>(); private final List<Loan> loans = new ArrayList<>(); void addBook(Book b) { books.put(b.isbn(), b); } void addMember(Member m) { members.put(m.id(), m); } private Optional<Loan> openLoanFor(String isbn) { return loans.stream().filter(l -> l.isOpen() && l.book.isbn().equals(isbn)).findFirst(); } private long openLoansOf(Member m) { return loans.stream().filter(l -> l.isOpen() && l.member.equals(m)).count(); } LoanResult borrow(int memberId, String isbn, LocalDate date) { Member m = members.get(memberId); Book b = books.get(isbn); if (m == null || b == null) throw new LibraryException("Unknown member or book"); if (openLoanFor(isbn).isPresent()) return new Refused(b.title() + " is already on loan"); if (openLoansOf(m) >= m.type().maxLoans) return new Refused(m.name() + " has reached the loan limit"); double unpaid = loans.stream().filter(l -> l.member.equals(m)).mapToDouble(l -> l.feeOn(date)).sum(); if (unpaid >= 5) return new Refused(m.name() + " owes %.2f in late fees".formatted(unpaid)); Loan loan = new Loan(b, m, date); loans.add(loan); return new Borrowed(b.title(), loan.due); } double giveBack(String isbn, LocalDate date) { Loan loan = openLoanFor(isbn).orElseThrow(() -> new LibraryException("Book " + isbn + " is not on loan")); loan.close(date); return loan.feeOn(date); } List<Loan> overdue(LocalDate today) { return loans.stream() .filter(l -> l.isOpen() && today.isAfter(l.due)) .sorted(Comparator.comparing((Loan l) -> l.due)) .toList(); } Map<Genre, Long> loansByGenre() { return loans.stream().collect(Collectors.groupingBy(l -> l.book.genre(), TreeMap::new, Collectors.counting())); } } public class Main { static String describe(LoanResult r) { return switch (r) { case Borrowed(var title, var due) -> "OK: " + title + ", due " + due; case Refused(var reason) -> "REFUSED: " + reason; }; } public static void main(String[] args) { Library lib = new Library(); lib.addBook(new Book("9780000000011", "Small Rivers", "R. Okoye", Genre.FICTION)); lib.addBook(new Book("9780000000028", "The Patient Gardener", "L. Moreno", Genre.SCIENCE)); lib.addBook(new Book("9780000000035", "Night Sky Atlas", "R. Okoye", Genre.SCIENCE)); lib.addBook(new Book("9780000000042", "Harbour Towns", "M. Silva", Genre.HISTORY)); lib.addMember(new Member(1, "Ana", MemberType.STANDARD)); lib.addMember(new Member(2, "Ben", MemberType.STUDENT)); LocalDate day = LocalDate.of(2026, 3, 2); System.out.println(describe(lib.borrow(1, "9780000000011", day))); System.out.println(describe(lib.borrow(2, "9780000000011", day))); System.out.println(describe(lib.borrow(2, "9780000000035", day))); System.out.println(describe(lib.borrow(1, "9780000000028", day.plusDays(1)))); LocalDate later = LocalDate.of(2026, 4, 10); System.out.println("\nOverdue on " + later + ":"); for (Loan l : lib.overdue(later)) { System.out.println(" %-22s %-4s due %s fee %.2f".formatted(l.book.title(), l.member.name(), l.due, l.feeOn(later))); } System.out.println("\nAna returns Small Rivers, fee: %.2f".formatted(lib.giveBack("9780000000011", later))); System.out.println(describe(lib.borrow(1, "9780000000042", later))); try { lib.giveBack("9780000000042", later); lib.giveBack("9780000000042", later); } catch (LibraryException e) { System.out.println("Error: " + e.getMessage()); } System.out.println("\nLoans by genre: " + lib.loansByGenre()); try { new Book("123", "Bad", "Nobody", Genre.FICTION); } catch (IllegalArgumentException e) { System.out.println("Rejected: " + e.getMessage()); } } }
Output
OK: Small Rivers, due 2026-03-16
REFUSED: Small Rivers is already on loan
OK: Night Sky Atlas, due 2026-03-23
OK: The Patient Gardener, due 2026-03-17
Overdue on 2026-04-10:
Small Rivers Ana due 2026-03-16 fee 6.25
The Patient Gardener Ana due 2026-03-17 fee 6.00
Night Sky Atlas Ben due 2026-03-23 fee 4.50
Ana returns Small Rivers, fee: 6.25
REFUSED: Ana owes 12.25 in late fees
Error: Book 9780000000042 is not on loan
Loans by genre: {FICTION=1, SCIENCE=2}
Rejected: ISBN must be 13 digits: 123Things to notice
- Validation at the edges: the
Bookrecord rejects bad ISBNs, so the rest of the code can trust every book. - Rules in one place: loan limits and loan lengths live in
MemberType; fees live inLoan. - Expected outcomes vs errors: a refused loan is a normal result (
Refused), while returning a book that isn't on loan is an error (LibraryException). - The sealed result forces
describeto handle every outcome. - Encapsulation:
Library's collections are private; callers use its methods.
Challenges
- Search: add
findByAuthor(String)returning books sorted by title. - Reservations: let members reserve a book that's on loan; on return, it's held for the first person in a queue (
ArrayDeque). - Payments: record fee payments so members can borrow again after paying.
- Persistence: save books to a CSV file and load them at start-up (lesson 51).
- Tests: write JUnit tests for the loan limit, fee cap and refused cases (lesson 54).
- Reports: the most borrowed author, and each member's total fees, using collectors.
- Structure: split the program into packages
model,serviceandappin a Maven or Gradle project (lessons 27 and 55).
What next?
- Test yourself with the exercises and the quiz.
- Store data in a real database with the SQL course.
- Explore another JVM language in the Kotlin course.