Final Project

Reviewed & published by Brayan K

You'll build a complete console + database application one milestone at a time — a Library Manager that models books, queries them with streams, persists them through JDBC, handles failure cleanly, is covered by tests, and ships as a single runnable jar.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Build in This Capstone

Before You Start

This capstone pulls together the whole course. The milestones lean most on classes & encapsulation, the Streams API, JDBC, exceptions, logging, unit testing, and Maven/Gradle. Each milestone is a worked example you can run before moving on — paste them into onecompiler.com/java (it bundles H2 and JUnit) or your own IDE.

📚 Real-World Analogy: Think of the app like a real library, built in layers:

🎉 One App, Six Milestones

This is your capstone — the lesson where every separate topic becomes one working application. You'll build it the way professionals do: in small, runnable milestones, each one verified before the next.

The golden rule for the whole build: keep the layers honest. The domain (Book) knows nothing about the database; the service depends on a repository interface, not on JDBC; and the database is just one swappable adapter. Get that separation right and you can change persistence, add a UI, or test in isolation without rewriting anything.

Milestone 1 — The Domain Model

Every app starts with its data and rules, not its database. A Book is more than a bag of fields — it has invariants (rules that must always be true): a title can't be blank, a year can't be impossible, and a book that's already out can't be borrowed again.

Putting that validation inside the constructor and the behaviour (borrow(), giveBack()) on the object itself means no other layer can ever create or corrupt an invalid book. This is encapsulation doing its job.

import java.time.Year;

public class Main {

    // A Book is a small, self-validating DOMAIN object.
    // It owns its rules so no other layer can build an invalid book.
    public static final class Book {
        private final long id;            // database identity (0 until saved)
        private final String isbn;
        private final String title;
        private final String author;
        private final int year;
        private boolean borrowed;         // mutable: state changes over time

        public Book(long id, String isbn, String title, String author, int year) {
            // Guard clauses validate at the door — fail fast, fail loud.
            if (isbn == null || isbn.isBlank())
                throw new IllegalArgumentException("ISBN cannot be blank");
            if (title == null || title.isBlank())
                throw new IllegalArgumentException("Title cannot be blank");
            if (year < 1450 || year > Year.now().getValue() + 1)
                throw new IllegalArgumentException("Year out of range: " + year);
            this.id = id;
            this.isbn = isbn.trim();
            this.title = title.trim();
            this.author = author == null ? "Unknown" : author.trim();
            this.year = year;
        }

        // Behaviour lives WITH the data (not in some external manager).
        public void borrow() {
            if (borrowed) throw new IllegalStateException(title + " is already on loan");
            borrowed = true;
        }
        public void giveBack() {
            if (!borrowed) throw new IllegalStateException(title + " was not on loan");
            borrowed = false;
        }

        public long id()        { return id; }
        public String isbn()    { return isbn; }
        public String title()   { return title; }
        public String author()  { return author; }
        public int year()       { return year; }
        public boolean isBorrowed() { return borrowed; }

        @Override public String toString() {
            return "%s %-28s by %-18s (%d)".formatted(
                borrowed ? "[OUT]" : "[ IN]", title, author, year);
        }
    }

    public static void main(String[] args) {
        Book clean = new Book(1, "978-0132350884", "Clean Code", "Robert Martin", 2008);
        Book effective = new Book(2, "978-0134685991", "Effective Java", "Joshua Bloch", 2018);

        System.out.println(clean);
        System.out.println(effective);

        clean.borrow();                       // valid: now on loan
        System.out.println("After borrowing: " + clean);

        // The model REFUSES to enter an invalid state:
        try {
            clean.borrow();                   // already out -> error
        } catch (IllegalStateException e) {
            System.out.println("Blocked: " + e.getMessage());
        }

        try {
            new Book(3, "", "No ISBN", "Nobody", 2020);   // blank isbn -> error
        } catch (IllegalArgumentException e) {
            System.out.println("Rejected: " + e.getMessage());
        }
    }
}

Milestone 2 — Collections & Stream Queries

A catalogue is a collection of books, and the questions you ask about it — "what's available?", "how many per author?", "which is oldest?" — are stream pipelines. A stream lets you describe the what (filter, map, sort, group, count) and lets Java handle the how.

Each pipeline reads top to bottom like a sentence. Notice none of them mutate the source list — every query derives a new result, leaving the catalogue intact.

import java.util.*;
import java.util.stream.Collectors;

public class Main {
    // A compact Book: the Milestone 1 class trimmed to what this stage needs.
    public static final class Book {
        private final String title, author; private final int year; private boolean borrowed;
        public Book(long id, String isbn, String title, String author, int year) {
            this.title = title; this.author = author; this.year = year;
        }
        public String title()  { return title; }
        public String author() { return author; }
        public int year()      { return year; }
        public boolean isBorrowed() { return borrowed; }
        public void borrow()   { borrowed = true; }
    }

    public static void main(String[] args) {
        // The catalogue is an in-memory List for now (Milestone 3 adds a DB).
        List<Book> catalogue = List.of(
            mk("Clean Code",        "Robert Martin", 2008, false),
            mk("Effective Java",    "Joshua Bloch",  2018, true),
            mk("The Pragmatic Programmer", "Hunt & Thomas", 1999, false),
            mk("Refactoring",       "Martin Fowler", 2018, false),
            mk("Java Concurrency",  "Brian Goetz",   2006, true)
        );

        // 1) FILTER: only the books currently available.
        List<Book> available = catalogue.stream()
            .filter(b -> !b.isBorrowed())
            .collect(Collectors.toList());
        System.out.println("Available: " + available.size() + " of " + catalogue.size());

        // 2) MAP + SORT: titles of 2018 books, alphabetised.
        List<String> recent = catalogue.stream()
            .filter(b -> b.year() == 2018)
            .map(Book::title)
            .sorted()
            .collect(Collectors.toList());
        System.out.println("From 2018: " + recent);

        // 3) GROUP: books per author, using groupingBy.
        Map<String, Long> byAuthor = catalogue.stream()
            .collect(Collectors.groupingBy(Book::author, Collectors.counting()));
        System.out.println("By author: " + new TreeMap<>(byAuthor));

        // 4) REDUCE: how many are on loan, with a single count().
        long onLoan = catalogue.stream().filter(Book::isBorrowed).count();
        System.out.println("On loan: " + onLoan);

        // 5) FIND: the oldest book, via min() + a Comparator.
        catalogue.stream()
            .min(Comparator.comparingInt(Book::year))
            .ifPresent(b -> System.out.println("Oldest: " + b.title() + " (" + b.year() + ")"));
    }

    // tiny factory so the example stays focused on the stream logic
    static Book mk(String t, String a, int y, boolean out) {
        Book b = new Book(0, "isbn-" + t.hashCode(), t, a, y);
        if (out) b.borrow();
        return b;
    }
}

🎯 Your Turn #1 — Enforce a borrowing limit

A Member can hold at most three books. Fill in the blanks so checkout() enforces that invariant and records the loan — the same guard-clause + behaviour pattern you used for Book in Milestone 1.

public class Main {

    public static final class Member {
        private final String name;
        private int booksOut = 0;
        private static final int LIMIT = 3;   // members may hold 3 books at once

        public Member(String name) { this.name = name; }

        // 🎯 YOUR TURN — fill in the blanks marked with ___

        public void checkout() {
            // 1) refuse if the member is already at the limit
            if (booksOut >= ___)              // 👉 replace ___ with LIMIT
                throw new IllegalStateException(name + " has too many books");

            // 2) otherwise record one more book on loan
            booksOut = ___;                   // 👉 replace ___ with booksOut + 1
        }

        public int booksOut() { return booksOut; }
    }

    public static void main(String[] args) {
        Member sam = new Member("Sam");
        sam.checkout();
        sam.checkout();
        System.out.println("Sam has " + sam.booksOut() + " books out");

        // ✅ Expected output:
        // Sam has 2 books out
    }
}

Milestone 3 — Persist with JDBC

Right now the catalogue lives in memory and vanishes when the program exits. JDBC is Java's standard way to talk to a relational database. The key idea is the repository port: your service depends on a BookRepository interface, and the JDBC class is just one adapter behind it — swap it for JPA or a file later without touching the rest of the app.

Every write uses a PreparedStatement: the ? placeholders keep your values separate from the SQL text, which is both safe (no SQL injection) and correct (the driver handles quoting and types). The example uses an H2 in-memory database so it runs anywhere; a real app just points the URL at PostgreSQL.

import java.sql.*;
import java.util.*;

public class Main {

    // The output PORT: the service depends on THIS, not on JDBC.
    interface BookRepository {
        long save(String isbn, String title, String author, int year);
        Optional<Row> findById(long id);
        List<Row> findAll();
    }
    record Row(long id, String isbn, String title, String author, int year) {}

    // A JDBC ADAPTER that implements the port. Swap it for JPA/file later.
    static final class JdbcBookRepository implements BookRepository {
        private final Connection conn;
        JdbcBookRepository(Connection conn) { this.conn = conn; }

        void createSchema() throws SQLException {
            try (Statement st = conn.createStatement()) {
                st.execute("""
                    CREATE TABLE books (
                        id     IDENTITY PRIMARY KEY,
                        isbn   VARCHAR(20) NOT NULL,
                        title  VARCHAR(200) NOT NULL,
                        author VARCHAR(100),
                        year   INT
                    )""");
            }
        }

        @Override public long save(String isbn, String title, String author, int year) {
            String sql = "INSERT INTO books(isbn, title, author, year) VALUES (?, ?, ?, ?)";
            // PreparedStatement: the ? placeholders keep VALUES separate from SQL.
            try (PreparedStatement ps =
                     conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
                ps.setString(1, isbn);     // bind by position — never concatenate!
                ps.setString(2, title);
                ps.setString(3, author);
                ps.setInt(4, year);
                ps.executeUpdate();
                try (ResultSet keys = ps.getGeneratedKeys()) {
                    keys.next();
                    return keys.getLong(1);   // the DB-assigned id
                }
            } catch (SQLException e) {
                throw new RuntimeException("save failed", e);
            }
        }

        @Override public Optional<Row> findById(long id) {
            String sql = "SELECT id, isbn, title, author, year FROM books WHERE id = ?";
            try (PreparedStatement ps = conn.prepareStatement(sql)) {
                ps.setLong(1, id);
                try (ResultSet rs = ps.executeQuery()) {
                    return rs.next() ? Optional.of(map(rs)) : Optional.empty();
                }
            } catch (SQLException e) { throw new RuntimeException("findById failed", e); }
        }

        @Override public List<Row> findAll() {
            String sql = "SELECT id, isbn, title, author, year FROM books ORDER BY id";
            List<Row> out = new ArrayList<>();
            try (PreparedStatement ps = conn.prepareStatement(sql);
                 ResultSet rs = ps.executeQuery()) {
                while (rs.next()) out.add(map(rs));
                return out;
            } catch (SQLException e) { throw new RuntimeException("findAll failed", e); }
        }

        private Row map(ResultSet rs) throws SQLException {
            return new Row(rs.getLong("id"), rs.getString("isbn"),
                rs.getString("title"), rs.getString("author"), rs.getInt("year"));
        }
    }

    public static void main(String[] args) throws SQLException {
        // H2 in-memory DB — no install needed. Real apps point this URL at PostgreSQL.
        String url = "jdbc:h2:mem:library;DB_CLOSE_DELAY=-1";
        try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
            JdbcBookRepository repo = new JdbcBookRepository(conn);
            repo.createSchema();

            long id1 = repo.save("978-0132350884", "Clean Code", "Robert Martin", 2008);
            long id2 = repo.save("978-0134685991", "Effective Java", "Joshua Bloch", 2018);
            System.out.println("Inserted ids: " + id1 + ", " + id2);

            System.out.println("All rows:");
            repo.findAll().forEach(r ->
                System.out.println("  #" + r.id() + " " + r.title() + " (" + r.year() + ")"));

            repo.findById(id2).ifPresent(r ->
                System.out.println("Found by id " + id2 + ": " + r.title()));
        }
    }
}

🎯 Your Turn #2 — Bind a PreparedStatement

The whole point of a PreparedStatement is binding values by position with the right typed setter. Fill in the blanks to insert a row and read it back — never concatenate the values into the SQL.

import java.sql.*;

public class Main {
    public static void main(String[] args) throws SQLException {
        String url = "jdbc:h2:mem:yt;DB_CLOSE_DELAY=-1";
        try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
            try (Statement st = conn.createStatement()) {
                st.execute("CREATE TABLE books(id IDENTITY, title VARCHAR(100), year INT)");
            }

            // 🎯 YOUR TURN — fill in the blanks marked with ___

            String sql = "INSERT INTO books(title, year) VALUES (?, ?)";
            try (PreparedStatement ps = conn.prepareStatement(sql)) {
                // 1) bind the title (a String) to the FIRST placeholder
                ps.___(1, "Clean Code");          // 👉 replace ___ with setString

                // 2) bind the year (an int) to the SECOND placeholder
                ps.setInt(2, ___);                // 👉 replace ___ with 2008

                int rows = ps.executeUpdate();
                System.out.println("Inserted rows: " + rows);
            }

            // 3) read it back to prove the insert worked
            try (PreparedStatement ps = conn.prepareStatement(
                     "SELECT title FROM books WHERE year = ?")) {
                ps.setInt(1, 2008);
                try (ResultSet rs = ps.executeQuery()) {
                    rs.next();
                    System.out.println("Found: " + rs.getString("title"));
                }
            }

            // ✅ Expected output:
            // Inserted rows: 1
            // Found: Clean Code
        }
    }
}

Milestone 4 — Exception Handling & Logging

Real apps fail: the database drops a connection, a user asks for a book that isn't there. Good code distinguishes the two. A missing book is a normal business case you catch and recover from; a dropped connection is infrastructure failure. The pattern: catch the low-level checked SQLException in the repository, log it with its cause, and re-throw your own clean unchecked DataAccessException so the rest of the app isn't littered with database plumbing.

And use a logger, not println — it gives you levels (INFO, WARNING, SEVERE), timestamps, and a switch to control output without editing code.

import java.sql.SQLException;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;
import java.util.logging.StreamHandler;

public class Main {

    // ONE logger per class — named after the class, configured externally.
    private static final Logger log = Logger.getLogger(Main.class.getName());
    // Route records to standard output as "LEVEL: message", flushed as they come,
    // so they land in order next to the println lines (the default handler
    // writes to stderr with a timestamp line first).
    static {
        System.setProperty("java.util.logging.SimpleFormatter.format", "%4$s: %5$s%n");
        StreamHandler out = new StreamHandler(System.out, new SimpleFormatter()) {
            @Override public synchronized void publish(java.util.logging.LogRecord r) { super.publish(r); flush(); }
        };
        log.setUseParentHandlers(false);
        log.addHandler(out);
    }

    // A domain-specific UNCHECKED exception. Callers don't catch SQLException;
    // the repository converts it into this so the rest of the app stays clean.
    static final class DataAccessException extends RuntimeException {
        DataAccessException(String message, Throwable cause) { super(message, cause); }
    }

    // Thrown when a book the user asked for simply isn't there.
    static final class BookNotFoundException extends RuntimeException {
        BookNotFoundException(long id) { super("No book with id " + id); }
    }

    // Simulates the repository call that can fail at the database boundary.
    static String loadTitle(long id) {
        try {
            if (id == 99) throw new SQLException("connection reset by peer");
            if (id == 42) return "Effective Java";
            throw new BookNotFoundException(id);
        } catch (SQLException e) {
            // Catch the LOW-LEVEL checked exception, log it WITH the cause,
            // and re-throw a clean unchecked one. Never swallow silently.
            log.severe("DB read failed for id " + id + ": " + e.getMessage());
            throw new DataAccessException("Could not load book " + id, e);
        }
    }

    public static void main(String[] args) {
        log.info("Library service starting up");

        // Happy path
        System.out.println("Loaded: " + loadTitle(42));

        // Expected business error — caught and handled, app keeps running.
        try {
            loadTitle(7);
        } catch (BookNotFoundException e) {
            log.warning("Lookup miss: " + e.getMessage());
            System.out.println("Handled: " + e.getMessage());
        }

        // Infrastructure failure — wrapped, cause preserved for debugging.
        try {
            loadTitle(99);
        } catch (DataAccessException e) {
            System.out.println("Wrapped:  " + e.getMessage()
                + " (cause: " + e.getCause().getMessage() + ")");
        }

        log.info("Shutdown clean");
    }
}

Milestone 5 — Lock It Down with JUnit

Tests are how you prove the rules from Milestone 1 still hold — and how you keep them holding as the app grows. Each JUnit 5 test is one focused claim: "a fresh book starts available", "borrowing twice is rejected". The @BeforeEach method builds a fresh fixture before every test so they never leak state into each other.

assertThrows is the workhorse for invariants: it asserts that the wrong move throws the right exception. A green suite means your domain is safe to refactor.

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("Book domain")
class BookTest {

    private Book book;

    // @BeforeEach gives every test a FRESH fixture — no leaks between tests.
    @BeforeEach
    void setUp() {
        book = new Book(1, "978-0132350884", "Clean Code", "Robert Martin", 2008);
    }

    @Test
    @DisplayName("a fresh book starts available")
    void startsAvailable() {
        assertFalse(book.isBorrowed());           // expect: not on loan
    }

    @Test
    @DisplayName("borrowing flips the book to on-loan")
    void borrowMarksBorrowed() {
        book.borrow();
        assertTrue(book.isBorrowed());            // expect: now out
    }

    @Test
    @DisplayName("borrowing twice is rejected")
    void cannotBorrowTwice() {
        book.borrow();
        IllegalStateException ex =
            assertThrows(IllegalStateException.class, book::borrow);
        assertTrue(ex.getMessage().contains("already on loan"));
    }

    @Test
    @DisplayName("a blank title is rejected at construction")
    void rejectsBlankTitle() {
        assertThrows(IllegalArgumentException.class,
            () -> new Book(2, "isbn-x", "   ", "Nobody", 2020));
    }

    @Test
    @DisplayName("an impossible year is rejected")
    void rejectsBadYear() {
        assertThrows(IllegalArgumentException.class,
            () -> new Book(3, "isbn-y", "Time Travel", "H. G. Wells", 1200));
    }
}

Milestone 6 — Package as a Runnable Jar

The last step turns your classes into something you can hand to someone: a single runnable jar. Two things make a jar runnable — a Main-Class entry in its manifest (so java -jar knows where to start), and all dependencies bundled inside (a "fat jar"). The maven-shade-plugin does both during mvn package.

After this, anyone with a JDK runs your whole app with one command — no classpath juggling, no missing libraries.

# 1) Maven layout (src/main/java holds your packages):
#    library/
#      pom.xml
#      src/main/java/com/example/library/Main.java
#      src/test/java/com/example/library/BookTest.java

# 2) In pom.xml, name the entry point AND bundle dependencies into ONE jar.
#    The shade plugin builds a "fat jar" so 'java -jar' needs nothing else.

<build>
  <finalName>library</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <version>3.5.1</version>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation=
                "org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>com.example.library.Main</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

# 3) Build it (runs your JUnit tests, then packages the jar):
$ mvn clean package

# 4) Run the finished app — one self-contained file, any machine with a JDK:
$ java -jar target/library.jar

# 5) Confirm the manifest names your Main-Class:
$ unzip -p target/library.jar META-INF/MANIFEST.MF | grep Main-Class

Stretch Challenge — Make It Yours

Support is faded now — only an outline. Add a real borrow / return feature that touches every layer you built: schema, repository, service rule, logging, and a test. Lean on the same patterns from the six milestones.

import java.sql.*;
import java.util.*;

public class Main {

    // 🏆 STRETCH CHALLENGE — outline only, you write the logic.
    //
    // Goal: add a working "borrow / return" feature to the JDBC repository,
    // wiring together every layer you built across the six milestones.
    //
    //  1. Schema (Milestone 3 style)
    //     - add a BOOLEAN column 'borrowed' to the books table
    //
    //  2. Repository methods (PreparedStatement, Milestone 3)
    //     - setBorrowed(long id, boolean borrowed):
    //         UPDATE books SET borrowed = ? WHERE id = ?
    //         bind with ps.setBoolean(1, ...) and ps.setLong(2, id)
    //     - availableCount(): SELECT COUNT(*) FROM books WHERE borrowed = FALSE
    //
    //  3. Service rule (Milestone 1 + 4)
    //     - borrow(id): load the row; if it's already borrowed,
    //       throw your own unchecked IllegalStateException; else flip it true
    //     - log each borrow at INFO level (Milestone 4)
    //
    //  4. Verify with a JUnit test (Milestone 5)
    //     - @BeforeEach: fresh in-memory DB + one book
    //     - borrowing twice throws IllegalStateException
    //
    // ✅ Expected: borrowing a free book succeeds and availableCount() drops by 1;
    //    borrowing an already-borrowed book is rejected; tests stay green.

    public static void main(String[] args) throws SQLException {
        // your code here
    }
}

Common Pitfalls (and the Fix)

1. Building SQL by string concatenation

Always bind values with a PreparedStatement — never paste user input into the query text.

2. Leaking JDBC resources

Connections, statements, and result sets all hold OS resources. Open them in try-with-resources so they always close.

3. Swallowing exceptions silently

An empty catch block hides bugs. Log the cause and re-throw a meaningful exception.

4. Tests that share state

If reordering your tests breaks them, they're leaking data into each other. Build a fresh fixture in @BeforeEach — a new in-memory DB or a clean object — so every test stands alone.

5. Anaemic domain objects

If Book is just getters and setters and the rules live in some BookManager, you've split the data from its behaviour. Keep validation and state changes (borrow(), giveBack()) on the object that owns the data.

6. The "no main manifest attribute" error

Your manifest must name the entry point. Configure mainClass in the shade plugin (Milestone 6) so the jar knows where to start.

Quick Reference — Techniques Used in This Build

TaskTechnique
Validate at construction
Filter a collection
Group + count
Open a DB connection
Insert safely
Read rows
Wrap a failure
Log with a level
Assert it throws
Build a runnable jar

Take It Further

🎉 Course Complete!

Congratulations — you've finished the Java course and shipped a real application!

You took the Library Manager from an empty class to a tested, persistent, packaged app: a domain model that enforces its own rules, stream queries over a collection, JDBC persistence with PreparedStatements, exception handling that wraps and logs failures, a JUnit suite that proves it works, and a one-command build into a runnable jar. That's the full loop of professional Java.

Where to go next:

Keep building, keep learning, and share what you've created. 🚀

Practice quiz

In the Library Manager, where does validation like 'a title cannot be blank' belong?

  • In a separate BookManager class
  • Only in the database schema
  • Inside the Book's constructor (guard clauses), so no layer can build an invalid book
  • In the JUnit tests only

Answer: Inside the Book's constructor (guard clauses), so no layer can build an invalid book. Putting validation in the constructor with guard clauses means the domain object enforces its own invariants — encapsulation doing its job.

Why do in-memory books disappear when the app restarts?

  • An ArrayList/HashMap lives in the heap and vanishes when the process ends; you must persist to a DB or file
  • The JVM deletes them on exit
  • Java garbage-collects them mid-run
  • They are saved but in the wrong folder

Answer: An ArrayList/HashMap lives in the heap and vanishes when the process ends; you must persist to a DB or file. In-memory collections live only in the JVM heap and are lost on exit; persisting rows via JDBC (Milestone 3) makes them survive restarts.

Why use a PreparedStatement with ? placeholders instead of concatenating SQL?

  • It runs without a database
  • It avoids needing a Connection
  • It is the only way to read rows
  • It prevents SQL injection by sending SQL and values separately, and the driver handles quoting/types

Answer: It prevents SQL injection by sending SQL and values separately, and the driver handles quoting/types. Binding values with setString/setInt keeps input as data, not code, blocking SQL injection and letting the driver quote and type correctly.

Which Collectors call counts books per author in a stream pipeline?

  • Collectors.toList()
  • Collectors.groupingBy(Book::author, Collectors.counting())
  • Collectors.joining()
  • Collectors.summingInt(Book::year)

Answer: Collectors.groupingBy(Book::author, Collectors.counting()). groupingBy with a counting downstream collector groups by author and counts each group. Verified on Java 21.

How does the repository keep the rest of the app independent of JDBC?

  • The service depends on a BookRepository interface (port); the JDBC class is one swappable adapter
  • By making every method static
  • By storing JDBC objects globally
  • By avoiding interfaces entirely

Answer: The service depends on a BookRepository interface (port); the JDBC class is one swappable adapter. The service depends on the repository interface; the JDBC implementation is just one adapter you could swap for JPA or a file.

What is the recommended exception strategy at the database boundary?

  • Let SQLException propagate through every layer
  • Swallow the SQLException silently
  • Catch the checked SQLException, log it with its cause, and rethrow an unchecked DataAccessException
  • Catch Throwable everywhere

Answer: Catch the checked SQLException, log it with its cause, and rethrow an unchecked DataAccessException. Catching SQLException and rethrowing your own unchecked DataAccessException (preserving the cause) keeps the app clean of database plumbing.

Why use a Logger instead of System.out.println in the app?

  • println is removed in Java 21
  • A logger gives levels, timestamps, the class name, and configurable output without editing code
  • Loggers are faster at all times
  • println cannot print strings

Answer: A logger gives levels, timestamps, the class name, and configurable output without editing code. A logger provides INFO/WARNING/SEVERE levels, timestamps, and external configuration so you can control output without code changes.

How do you keep JUnit tests from leaking data into each other?

  • Run them in alphabetical order
  • Share one static repository
  • Disable assertions
  • Use @BeforeEach to build a fresh fixture (e.g. new in-memory DB) before every test

Answer: Use @BeforeEach to build a fresh fixture (e.g. new in-memory DB) before every test. @BeforeEach creates a fresh fixture before each test so they stay independent and can run in any order.

Which JUnit 5 assertion checks that an invalid action throws the right exception?

  • assertEquals
  • assertThrows(IllegalStateException.class, book::borrow)
  • assertTrue
  • assertNull

Answer: assertThrows(IllegalStateException.class, book::borrow). assertThrows asserts that the given action throws the expected exception type, the workhorse for verifying invariants.

What two things make a JAR runnable with 'java -jar app.jar'?

  • A README and a license
  • A Dockerfile and a JRE
  • A Main-Class entry in the manifest and all dependencies bundled (a fat jar)
  • A test report and a tag

Answer: A Main-Class entry in the manifest and all dependencies bundled (a fat jar). A runnable jar needs a Main-Class in its manifest plus bundled dependencies; the maven-shade-plugin produces this fat jar during mvn package.

Continue this course

Frequently asked questions

Why do my books disappear every time I restart the app?

If you only keep books in an in-memory collection like an ArrayList or HashMap, they live in the JVM's heap and vanish when the program exits. To make them survive a restart you must persist them — write each one to a database table with a PreparedStatement (or to a file). On the next launch you read them back from that table. The whole point of Milestone 3 (JDBC persistence) is turning that in-memory list into rows that outlive the process.

Why should I use a PreparedStatement instead of just building the SQL string?

Two reasons. First, security: concatenating user input into SQL ("... WHERE title = '" + title + "'") opens you to SQL injection — a title like x'; DROP TABLE books;-- can wreck your database. A PreparedStatement sends the SQL and the values separately, so the input is always treated as data, never as code. Second, correctness and speed: the driver escapes quotes and types for you, and the database can cache the parsed query plan. Always bind values with setString/setInt/setLong, never string-concatenate them.

What's the difference between a checked and an unchecked exception, and which should my repository throw?

Checked exceptions (like SQLException) must be declared or caught — the compiler forces you to handle them. Unchecked exceptions (RuntimeException and its subclasses, like IllegalArgumentException) don't. The clean pattern for a capstone: let the low-level SQLException happen inside the repository, catch it there, and re-throw your own unchecked DataAccessException so the rest of the app isn't littered with try/catch for a database detail. Use checked exceptions only when the caller can realistically recover.

Why use a logging framework instead of System.out.println?

println writes one fixed string to standard out with no level, no timestamp, and no way to turn it off in production. A logger (java.util.logging here, or SLF4J/Logback in real projects) gives you levels (INFO, WARNING, SEVERE), timestamps, the class name, and a config file to control what gets recorded and where — console, file, or both — without touching your code. You can silence debug noise in production and still keep error logs. Reach for println only in tiny throwaway scripts.

My JUnit test passes locally but the data leaks between tests — what went wrong?

Almost always shared state: your tests reuse one repository or one database connection and the rows from test A are still there in test B. Give each test a fresh fixture. With JUnit 5, annotate a setup method with @BeforeEach so a brand-new in-memory database (or a freshly truncated table) is created before every single test. Tests must be independent and able to run in any order; if reordering them breaks them, they're sharing state.

How do I turn my compiled classes into a single runnable jar I can hand to someone?

You need two things in the jar's manifest and packaging: a Main-Class entry that names your entry-point class, and all your dependencies bundled in (a "fat" or "uber" jar). With Maven, the maven-shade-plugin builds that fat jar during mvn package; with Gradle, the shadow plugin or a jar task with a manifest does it. Then anyone with a JDK runs it with java -jar library.jar — no classpath juggling. Milestone 6 walks through the manifest and the build config.

Is this capstone good enough to put on my CV or GitHub?

Yes — a console app with a clean domain model, stream-based queries, real JDBC persistence with PreparedStatements, proper exception handling and logging, a JUnit test suite, and a one-command build into a runnable jar demonstrates the full loop employers care about. Polish it: add a README explaining your architecture decisions, keep the layers separate (domain / service / repository), make sure mvn test is green, and tag a release. That's a legitimate portfolio piece.

Related lessons