Final Project

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.

Learn Final Project in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick reference.

Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

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:

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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

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

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

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.

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.

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

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.

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.