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

TypeRoleLessons
Book, Member (records)Immutable data with validation32
Genre, MemberType (enums)Fixed categories with behaviour33
LoanA loan with due date and fee calculation25, 50
LoanResult (sealed)The possible outcomes of borrowing49
LibraryExceptionDomain errors37
LibraryThe service holding state and rules26, 40–42
MainRuns a scenario and prints reports46–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: 123

Things to notice

  • Validation at the edges: the Book record 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 in Loan.
  • 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 describe to handle every outcome.
  • Encapsulation: Library's collections are private; callers use its methods.

Challenges

  1. Search: add findByAuthor(String) returning books sorted by title.
  2. Reservations: let members reserve a book that's on loan; on return, it's held for the first person in a queue (ArrayDeque).
  3. Payments: record fee payments so members can borrow again after paying.
  4. Persistence: save books to a CSV file and load them at start-up (lesson 51).
  5. Tests: write JUnit tests for the loan limit, fee cap and refused cases (lesson 54).
  6. Reports: the most borrowed author, and each member's total fees, using collectors.
  7. Structure: split the program into packages model, service and app in a Maven or Gradle project (lessons 27 and 55).

What next?