Skip to main content

    Lesson 50 • Capstone

    Final Project – Build a Library Manager

    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.

    What You'll Build in This Capstone

    • A self-validating Book domain model that owns its own rules
    • Stream-powered queries: filter, map, group, and reduce the catalogue
    • Real JDBC persistence with PreparedStatement and generated keys
    • Clean exception handling that wraps SQL errors and logs them
    • A JUnit 5 test suite that pins the domain behaviour down
    • A one-command Maven build that produces a runnable jar

    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:

    • • The Book object is a physical book with its own rules — you can't check out a book that's already out.
    • • The catalogue queries are the librarian skimming the shelves to answer "what's available?"
    • • The database is the card catalogue in the back room — it remembers every book even after the lights go off.
    • • The tests are the inventory audit that proves the rules still hold before you open the doors.

    🎉 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.

    Milestone 1 — A self-validating Book entity
    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());
            }
        }
    }
    Output
    [ IN] Clean Code                    by Robert Martin       (2008)
    [ IN] Effective Java                 by Joshua Bloch        (2018)
    After borrowing: [OUT] Clean Code                    by Robert Martin       (2008)
    Blocked: Clean Code is already on loan
    Rejected: ISBN cannot be blank
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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.

    Milestone 2 — Query the catalogue with streams
    import java.util.*;
    import java.util.stream.Collectors;
    
    public class Main {
        // (Book class from Milestone 1 lives here too — omitted for brevity.)
    
        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;
        }
        // Book class definition goes here (see Milestone 1).
    }
    Output
    Available: 3 of 5
    From 2018: [Effective Java, Refactoring]
    By author: {Brian Goetz=1, Hunt & Thomas=1, Joshua Bloch=1, Martin Fowler=1, Robert Martin=1}
    On loan: 2
    Oldest: The Pragmatic Programmer (1999)
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🎯 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.

    🎯 Your Turn #1 — Member.checkout()
    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
        }
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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.

    Milestone 3 — A JDBC repository with PreparedStatement
    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()));
            }
        }
    }
    Output
    Inserted ids: 1, 2
    All rows:
      #1 Clean Code (2008)
      #2 Effective Java (2018)
    Found by id 2: Effective Java
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🎯 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.

    🎯 Your Turn #2 — setString / setInt
    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
            }
        }
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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.

    Milestone 4 — Wrap failures, log with context
    import java.sql.SQLException;
    import java.util.logging.Logger;
    
    public class Main {
    
        // ONE logger per class — named after the class, configured externally.
        private static final Logger log = Logger.getLogger(Main.class.getName());
    
        // 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");
        }
    }
    Output
    INFO: Library service starting up
    Loaded: Effective Java
    WARNING: Lookup miss: No book with id 7
    Handled: No book with id 7
    SEVERE: DB read failed for id 99: connection reset by peer
    Wrapped:  Could not load book 99 (cause: connection reset by peer)
    INFO: Shutdown clean
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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.

    Milestone 5 — JUnit 5 tests for the domain
    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));
        }
    }
    Add junit-jupiter to your pom.xml and run mvn test. These run in milliseconds because the domain has zero framework dependencies. (The Book class from Milestone 1 lives in the same project.)

    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.

    Milestone 6 — Build and run the jar
    # 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
    Output
    [INFO] BUILD SUCCESS
    [INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
    
    $ java -jar target/library.jar
    Library ready. 2 books in catalogue.
    
    $ unzip -p target/library.jar META-INF/MANIFEST.MF | grep Main-Class
    Main-Class: com.example.library.Main
    These are shell commands and a pom.xml snippet — run them in your own terminal where Maven and a JDK are installed.

    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.

    🏆 Stretch Challenge — borrow / return across all layers
    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
        }
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    Common Pitfalls (and the Fix)

    1. Building SQL by string concatenation

    "... WHERE title = '" + title + "'"      // ❌ SQL injection waiting to happen
    conn.prepareStatement("... WHERE title = ?") // ✅ bind with ps.setString(1, title)

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

    2. Leaking JDBC resources

    Connection c = DriverManager.getConnection(url); // ❌ never closed -> leak
    try (Connection c = DriverManager.getConnection(url)) { ... } // ✅ auto-closed

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

    3. Swallowing exceptions silently

    catch (SQLException e) { }                 // ❌ failure vanishes without a trace
    catch (SQLException e) {                    // ✅ log it, then wrap + rethrow
        log.severe(e.getMessage());
        throw new DataAccessException("save failed", e);
    }

    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

    $ java -jar library.jar
    no main manifest attribute, in library.jar     // ❌ no Main-Class set

    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 constructionif (title.isBlank()) throw new IllegalArgumentException(...)
    Filter a collectionlist.stream().filter(b -> !b.isBorrowed())
    Group + countCollectors.groupingBy(Book::author, counting())
    Open a DB connectionDriverManager.getConnection(url, user, pass)
    Insert safelyps.setString(1, v); ps.executeUpdate();
    Read rowswhile (rs.next()) { rs.getString("title"); }
    Wrap a failurethrow new DataAccessException(msg, sqlEx)
    Log with a levellog.warning("Lookup miss: " + e.getMessage())
    Assert it throwsassertThrows(IllegalStateException.class, book::borrow)
    Build a runnable jarmvn clean package → java -jar target/library.jar

    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.

    Take It Further

    • 💡 Level 1 — Add a Member table and a borrowings join table so you can track who has what.
    • 💡 Level 2 — Wrap the app in a real CLI (picocli or a Scanner menu) so a user can add, list, and search books interactively.
    • 💡 Level 3 — Swap H2 for PostgreSQL, add a connection pool (HikariCP), and externalise the URL into a config file.
    • 💡 Level 4 — Put a Spring Boot REST API in front of the same repository, with DTOs and bean validation.
    • 💡 Level 5 — Add Flyway database migrations and a GitHub Actions pipeline that runs mvn test on every push.

    🎉 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:

    • Ship it: push it to GitHub with a README explaining your layered architecture, tag a release, and add it to your portfolio.
    • Add a framework: rebuild the persistence layer with Spring Data JPA — you already understand the repository pattern it's built on.
    • Go to the web: wrap it in a Spring Boot REST API and deploy it to a free cloud host.
    • Keep practising: revisit the Clean Architecture and REST APIs lessons and apply them to this project.

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

    Sign up for free to track which lessons you've completed and get learning reminders.

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service