Lesson 25 of 25

Final Project: Build a Java App

What You Are Building, and Why This One

This final project is a Library Management System: a console application that keeps a catalogue of books, issues and returns them, tracks who has what, reports overdue loans, and saves everything to a file so the data survives between runs.

It is deliberately chosen because every concept in this course has an honest place in it, rather than being bolted on to tick a box. Books and members are natural classes. The catalogue wants a Map keyed by ISBN so lookups are instant. A book that is already issued is exactly the situation a custom exception describes. Saving to disk is file handling. Producing a list of overdue titles is a stream pipeline. Nothing here is an exercise pretending to be an application.

The way to build it is in working increments, not all at once. Get the classes compiling with a hard-coded book or two. Then add the catalogue and a way to list it. Then issue and return. Then persistence. Then the menu. At every stage you should have something you can run, because debugging fifty new lines is straightforward and debugging five hundred is not.

Treat this as portfolio work, not homework. A completed, well-organised project on your GitHub profile that you can talk through in an interview is worth considerably more than another certificate. Interviewers ask why you chose a HashMap over a List, and being able to answer from your own code is the point.

  • Classes and objects — Book, Member, Loan, Library
  • Encapsulation — private fields, validation in constructors, no leaking of internal collections
  • Collections — a Map for the catalogue, a Map for active loans, a List for history
  • Exception handling — a custom checked exception for a book that is already issued
  • File I/O — saving and loading the catalogue as CSV, with the failure cases handled
  • Streams and lambdas — searching, filtering overdue loans, and producing summary counts
  • Records and enums — for data carriers and for fixed sets of values
Notes
  • Before writing any code, write down the operations the system must support in plain English: add a book, list all books, search by author, issue a book to a member, return a book, show overdue loans, save, load. That list becomes your public methods, and having it up front stops the design drifting.

Step 1: The Domain Classes

Start with the things the system talks about. Book is a real object with behaviour — it can be issued and returned, and it must never be issued twice — so it is a class with private fields. Member and Loan are pure data, so they are records, which gives you a constructor, accessors, equals, hashCode and toString for free.

Notice what Book does not have: a setAvailable(boolean). Availability changes only as a consequence of issuing or returning, so the methods are named after those operations. That is encapsulation working as intended — there is no way for calling code to mark a book available while it is still on loan.

The ISBN is the natural unique identifier, so it is final and doubles as the key in the catalogue. Because it never changes, it is also safe to base equals and hashCode on, which avoids the mutable-key problem from the HashMap lesson.

The custom exception is checked, extending Exception rather than RuntimeException. That is a design decision worth being able to justify: a book being on loan is a normal, expected situation the caller should handle by telling the user, not a programming bug — so the compiler should insist on handling it.

Example
import java.time.LocalDate;
import java.util.Objects;

// Book.java — a real object with rules of its own
public class Book {
    private final String isbn;
    private final String title;
    private final String author;
    private boolean available = true;

    public Book(String isbn, String title, String author) {
        if (isbn == null || isbn.isBlank()) {
            throw new IllegalArgumentException("ISBN is required");
        }
        this.isbn = isbn.strip();
        this.title = Objects.requireNonNull(title, "title").strip();
        this.author = Objects.requireNonNull(author, "author").strip();
    }

    public String getIsbn()    { return isbn; }
    public String getTitle()   { return title; }
    public String getAuthor()  { return author; }
    public boolean isAvailable() { return available; }

    // No setAvailable(). State changes only through real operations.
    void markIssued()   { this.available = false; }
    void markReturned() { this.available = true; }

    @Override public boolean equals(Object o) {
        return o instanceof Book b && b.isbn.equals(this.isbn);
    }
    @Override public int hashCode() { return isbn.hashCode(); }

    @Override public String toString() {
        return String.format("%-14s %-30s %-20s %s",
                isbn, title, author, available ? "Available" : "On loan");
    }
}

// Member.java and Loan.java — pure data, so records
public record Member(String memberId, String name) { }

public record Loan(String isbn, String memberId,
                   LocalDate issuedOn, LocalDate dueOn) { }

// A checked exception: the caller can and should recover
public class BookNotAvailableException extends Exception {
    public BookNotAvailableException(String title) {
        super("\"" + title + "\" is currently on loan");
    }
}
Notes
  • markIssued() and markReturned() are package-private — no modifier at all — so only classes in the same package, meaning Library, can call them. Outside code must go through library.issue(...), which enforces the rules. This is the principle of least privilege applied deliberately.

Step 2: The Library Class

Library holds the data and enforces the rules. Every decision in it is one you have made before in this course, and each is worth being able to explain.

The catalogue is a Map<String, Book> keyed by ISBN, not a List<Book>. Issuing a book means finding it by ISBN, and a map does that instantly where a list would scan. Using a LinkedHashMap keeps insertion order, so listing the catalogue produces a stable, sensible order rather than a scrambled one.

Active loans are a second Map, keyed by ISBN because a book can be on loan to at most one member at a time. Returning a book becomes a single remove. A separate List keeps the full history, since a list allows duplicates and preserves chronological order, which is exactly what history needs.

The issue method shows the failure cases handled distinctly. A missing ISBN and an already-issued book are different problems, deserve different messages, and get different exception types. Both checks happen before anything is modified, so a failed issue leaves the library exactly as it was — a small discipline that prevents a large class of bugs.

Finally, note that getCatalogue() returns List.copyOf(...). Handing out the live collection would let any caller clear the entire library, and every rule in this class would be bypassed.

Example
import java.time.LocalDate;
import java.util.*;

public class Library {
    private static final int LOAN_DAYS = 14;

    private final Map<String, Book> catalogue = new LinkedHashMap<>();
    private final Map<String, Member> members = new HashMap<>();
    private final Map<String, Loan> activeLoans = new HashMap<>();   // key = isbn
    private final List<Loan> history = new ArrayList<>();

    public void addBook(Book book) {
        if (catalogue.containsKey(book.getIsbn())) {
            throw new IllegalArgumentException("Duplicate ISBN: " + book.getIsbn());
        }
        catalogue.put(book.getIsbn(), book);
    }

    public void addMember(Member member) {
        members.put(member.memberId(), member);
    }

    public Loan issue(String isbn, String memberId) throws BookNotAvailableException {
        Book book = catalogue.get(isbn);
        if (book == null) {
            throw new NoSuchElementException("No book with ISBN " + isbn);
        }
        if (!members.containsKey(memberId)) {
            throw new NoSuchElementException("No member with ID " + memberId);
        }
        if (!book.isAvailable()) {
            throw new BookNotAvailableException(book.getTitle());
        }

        LocalDate today = LocalDate.now();
        Loan loan = new Loan(isbn, memberId, today, today.plusDays(LOAN_DAYS));
        book.markIssued();
        activeLoans.put(isbn, loan);
        history.add(loan);
        return loan;
    }

    public void returnBook(String isbn) {
        Loan loan = activeLoans.remove(isbn);
        if (loan == null) {
            throw new IllegalStateException("That book is not currently on loan");
        }
        catalogue.get(isbn).markReturned();
    }

    // ---- Queries, written as streams ----
    public List<Book> searchByAuthor(String author) {
        return catalogue.values().stream()
                .filter(b -> b.getAuthor().equalsIgnoreCase(author.strip()))
                .toList();
    }

    public List<Book> searchByTitle(String text) {
        String needle = text.toLowerCase();
        return catalogue.values().stream()
                .filter(b -> b.getTitle().toLowerCase().contains(needle))
                .toList();
    }

    public long countAvailable() {
        return catalogue.values().stream().filter(Book::isAvailable).count();
    }

    public List<Loan> overdueLoans() {
        LocalDate today = LocalDate.now();
        return activeLoans.values().stream()
                .filter(l -> l.dueOn().isBefore(today))
                .sorted(Comparator.comparing(Loan::dueOn))
                .toList();
    }

    public List<Book> getCatalogue() {
        return List.copyOf(catalogue.values());   // never hand out the real map
    }
}
Notes
  • LocalDate from java.time is the modern date class and is immutable — plusDays(14) returns a new date rather than changing the original. Avoid the older java.util.Date and Calendar, which are mutable and were replaced for good reasons.

Step 3: Making the Data Survive

Right now everything vanishes when the program exits. Saving the catalogue to a text file and reloading it at startup is what turns this from an exercise into an application.

CSV is the right format here: one line per book, fields separated by commas. It is readable, you can open it in a spreadsheet to check your work, and it needs no library. Its limitation is real, though — if a title contains a comma, a naive split produces the wrong number of fields. For a learning project, either avoid such titles in your test data or escape them; for anything real, use a proper CSV library.

The loading method has to be defensive, because a data file is outside your control. It might not exist yet on the very first run, which is not an error — return quietly. It might contain a blank line at the end, which almost every text editor adds. It might have a row with the wrong number of fields because someone edited it. Handle each of those, and report the line number when you skip something so the user can fix their file.

Notice that the failure handling is split by responsibility. Library reports what it could not parse; main decides what to do about a missing file. That separation is the same principle as the exception-handling lesson's advice to handle errors at the layer that knows what to do about them.

Example
import java.io.IOException;
import java.nio.file.*;
import java.util.List;

// Add these methods to Library
public void save(Path file) throws IOException {
    List<String> lines = catalogue.values().stream()
            .map(b -> String.join(",",
                    b.getIsbn(), b.getTitle(), b.getAuthor(),
                    String.valueOf(b.isAvailable())))
            .toList();
    Files.write(file, lines);          // overwrites — correct for a full snapshot
    System.out.println("Saved " + lines.size() + " books to " + file);
}

public void load(Path file) throws IOException {
    if (Files.notExists(file)) {
        return;                         // first run — nothing to load, not an error
    }
    List<String> lines = Files.readAllLines(file);

    for (int i = 0; i < lines.size(); i++) {
        String line = lines.get(i);
        if (line.isBlank()) continue;

        String[] parts = line.split(",");
        if (parts.length != 4) {
            System.out.println("Line " + (i + 1) + ": expected 4 fields, found "
                               + parts.length + " — skipped");
            continue;
        }

        try {
            Book book = new Book(parts[0], parts[1], parts[2]);
            if (!Boolean.parseBoolean(parts[3].strip())) {
                book.markIssued();
            }
            catalogue.put(book.getIsbn(), book);
        } catch (IllegalArgumentException e) {
            System.out.println("Line " + (i + 1) + ": " + e.getMessage() + " — skipped");
        }
    }
    System.out.println("Loaded " + catalogue.size() + " books");
}
Notes
  • Saving a full snapshot each time — overwriting rather than appending — is the right choice here, because the file must reflect the current state exactly. Appending is for logs, where every entry is a separate historical fact.

Step 4: The Menu, and Where to Take It Next

The last piece is the part the user actually touches. A do-while loop is exactly right — the menu must be shown before you can know whether the user wants to quit — and an arrow-form switch dispatches each choice with no break to forget.

Two details make the difference between a program that survives a demonstration and one that does not. Read every input with nextLine() and parse it yourself, which avoids the newline trap from the input lesson entirely. And put the try-catch around each operation rather than around the whole loop, so a bad ISBN prints a message and returns to the menu instead of ending the program.

Save on exit, and load at startup. It is two lines and it is the difference between a toy and a tool.

Once it works, the extensions below are where the real learning is. Each one forces a design decision rather than more typing — which is exactly what an interviewer will probe when you talk about the project. If you attempt only one, make it the tests: writing JUnit tests for Library.issue() teaches you more about your own design than any amount of extra features.

Example
import java.io.IOException;
import java.nio.file.*;
import java.util.*;

public class LibraryApp {
    private static final Path DATA_FILE = Path.of("library.csv");

    public static void main(String[] args) throws IOException {
        Scanner sc = new Scanner(System.in);
        Library library = new Library();

        library.load(DATA_FILE);
        library.addMember(new Member("M101", "Ananya"));

        String choice;
        do {
            System.out.println("\n=== Library ===");
            System.out.println("1. List all books");
            System.out.println("2. Add a book");
            System.out.println("3. Issue a book");
            System.out.println("4. Return a book");
            System.out.println("5. Search by author");
            System.out.println("6. Overdue loans");
            System.out.println("0. Save and exit");
            System.out.print("Choice: ");
            choice = sc.nextLine().strip();

            try {
                switch (choice) {
                    case "1" -> library.getCatalogue().forEach(System.out::println);

                    case "2" -> {
                        System.out.print("ISBN: ");   String isbn = sc.nextLine();
                        System.out.print("Title: ");  String title = sc.nextLine();
                        System.out.print("Author: "); String author = sc.nextLine();
                        library.addBook(new Book(isbn, title, author));
                        System.out.println("Added.");
                    }

                    case "3" -> {
                        System.out.print("ISBN: ");      String isbn = sc.nextLine();
                        System.out.print("Member ID: "); String id = sc.nextLine();
                        Loan loan = library.issue(isbn.strip(), id.strip());
                        System.out.println("Issued. Due on " + loan.dueOn());
                    }

                    case "4" -> {
                        System.out.print("ISBN: "); library.returnBook(sc.nextLine().strip());
                        System.out.println("Returned.");
                    }

                    case "5" -> {
                        System.out.print("Author: ");
                        List<Book> found = library.searchByAuthor(sc.nextLine());
                        if (found.isEmpty()) System.out.println("No matches.");
                        else found.forEach(System.out::println);
                    }

                    case "6" -> {
                        List<Loan> overdue = library.overdueLoans();
                        if (overdue.isEmpty()) System.out.println("Nothing overdue.");
                        else overdue.forEach(System.out::println);
                    }

                    case "0" -> library.save(DATA_FILE);

                    default  -> System.out.println("Please choose 0-6.");
                }
            } catch (BookNotAvailableException | NoSuchElementException
                     | IllegalArgumentException | IllegalStateException e) {
                System.out.println("Error: " + e.getMessage());   // menu continues
            }

        } while (!choice.equals("0"));

        System.out.println("Goodbye.");
    }
}
  • Add a fine calculation — a rupee amount per day overdue, using ChronoUnit.DAYS.between
  • Limit each member to three books at a time, which forces you to index loans by member as well as by ISBN
  • Introduce an enum BookCategory and report counts per category with Collectors.groupingBy
  • Add an abstract class LibraryItem with Book and Magazine as subclasses, each with a different loan period
  • Replace the CSV file with a small SQLite or MySQL database using JDBC
  • Write JUnit tests for issue(), returnBook() and overdueLoans() — the most valuable extension of all
  • Put it on GitHub with a README explaining how to run it and why you chose each data structure
Notes
  • When you demonstrate this project, be ready for the design questions rather than the feature list. Why a Map and not a List? Why is BookNotAvailableException checked while the others are unchecked? Why does getCatalogue() return a copy? Answering those clearly is what shows you understood the course rather than followed it.
  • You have now covered the whole of core Java: syntax, object-oriented design, collections, generics, exceptions, files and the functional style. That is the foundation everything else in the Java ecosystem is built on — Spring, Android, Hadoop and the rest are libraries written in exactly the language you have just learned.
Ask AI