Lesson 58Phase 6: Functional and modern Java
Project: build a console app end to end
A guided project that pulls the core course together: a library manager with classes, collections, files and error handling.
By BasithUpdated 60 min
- project
- class design
- records
- List and Map
- custom exceptions
- saving to a file
When this course began, an empty file was a mystery and printing one line felt like a win. Since then you have learned objects and interfaces, records, lists and maps, exceptions, files and streams, one idea at a time. This lesson adds no new keyword. It does the thing all of those lessons were building toward: it takes them together and builds one small program, end to end, that remembers its data between runs.
- Turn a short description into a class design before writing any code.
- Pick the right tool for each part: a record for fixed data, a
Listfor the catalogue, aMapfor the loans. - Report failures with your own checked exception, and handle them where the user can see the message.
- Save the catalogue to a file and load it back, so the program remembers.
- Drive the program from a thin keyboard menu, with the logic in classes you can test on their own.
- See the course so far on one map, and know what the phases after it add.
This is where it comes together#
Up to now each lesson taught one idea at a time. A loop here, a constructor there. Real programs are not like that. A real program is many small ideas working at once: a record holds some data, a list holds many records, a map remembers who has what, a method changes it, an exception reports what went wrong, and a file keeps it all when the program stops. Engineering is the skill of fitting those pieces together so the whole thing does something useful and does not fall over.
Key ideaLearning the pieces and building with the pieces are two different skills. You have the first. This lesson is a full practice run at the second.
We will build a small library manager: a program that keeps a catalogue of books, lets people borrow and return them, shows what is still on the shelf, and saves the catalogue to a file so it is still there next time. Small enough to finish, real enough to feel like software. We design it first, build the core and run it at every step, and add the keyboard menu last.
Step one: design the classes#
Before any Java, ask the two questions from why objects exist: what things are in this program, and for each thing, what does it have and what can it do? Read the description and underline the nouns.
“A library keeps a catalogue of books. People can borrow a book and return it. The catalogue is saved to a file.” The nouns give you the shape. A Book is one entry in the catalogue. A Library holds all the books and knows who has which. The file is not a class at all: it is somewhere the library writes to and reads from.
title and an author.List of books, the catalogue.Map from a title to the person who has it, the loans.record.add, find, borrow, giveBack, list what is on the shelf, save and load.Step two: the Book, as a record#
A book's title and author never change, which is exactly what a record is for. One line gives you the constructor, the accessors title() and author(), a readable toString, and an equals that compares the contents. Run it and step through.
public class Main { public static void main(String[] args) { Book dune = new Book("Dune", "Herbert"); Book copy = new Book("Dune", "Herbert"); System.out.println(dune.title() + " by " + dune.author()); System.out.println(dune); System.out.println(dune.equals(copy)); System.out.println(dune == copy); }} record Book(String title, String author) {}It prints Dune by Herbert, then Book[title=Dune, author=Herbert], then true and false. The memory view shows two separate Book objects, so ==, which asks “is this the same object?”, says no. equalsasks “do they hold the same title and author?”, and a record answers that for you. That matters more than it looks, because lists and maps use equals when they search.
books.add(new Book("Dune", "Herbert"));. What does books.contains(new Book("Dune", "Herbert")) give?Step three: the Library, with a List and a Map#
Now the class that does the work. It holds a List<Book> for the catalogue and a Map<String, String> for the loans, from a title to the name of the person who has it. Each method does one job:
- 1
add: put a book in the list. The list grows by itself, so there is no size to worry about. - 2
find: walk the list and return the book whose title matches, ignoring upper and lower case. If there is none, return an emptyOptionalrather thannull. - 3
borrow: find the book; if it is not already inloans, put it there with the borrower's name. - 4
giveBack: find the book and remove its loan. - 5
onShelf: the titles of every book that is not inloans, built with a stream.
Here it is, running. Step through and watch the list fill up, then watch an entry appear in the map when Emma is borrowed and disappear again when she comes back.
import java.util.*; public class Main { public static void main(String[] args) { Library library = new Library(); library.add(new Book("Dune", "Herbert")); library.add(new Book("Emma", "Austen")); library.add(new Book("Solaris", "Lem")); System.out.println("On the shelf: " + library.onShelf()); library.borrow("Emma", "Asha"); library.borrow("Mistborn", "Ravi"); System.out.println("On the shelf: " + library.onShelf()); System.out.println("Out: " + library.loans); library.giveBack("Emma"); System.out.println("On the shelf: " + library.onShelf()); }} record Book(String title, String author) {} class Library { List<Book> books = new ArrayList<>(); Map<String, String> loans = new HashMap<>(); // title -> who has it void add(Book book) { books.add(book); } Optional<Book> find(String title) { for (Book b : books) { if (b.title().equalsIgnoreCase(title)) { return Optional.of(b); } } return Optional.empty(); } void borrow(String title, String who) { Optional<Book> found = find(title); if (found.isEmpty()) { System.out.println("No book called " + title); } else if (loans.containsKey(found.get().title())) { System.out.println(title + " is already out"); } else { loans.put(found.get().title(), who); System.out.println(who + " borrowed " + found.get().title()); } } void giveBack(String title) { find(title).ifPresent(b -> { loans.remove(b.title()); System.out.println(b.title() + " is back"); }); } List<String> onShelf() { return books.stream() .filter(b -> !loans.containsKey(b.title())) .map(Book::title) .toList(); }}Trace what happened. Three add calls put three books in the list. borrow("Emma", "Asha") finds Emma and records the loan, so the map holds {Emma=Asha} and the next shelf listing leaves her out. borrow("Mistborn", "Ravi") finds nothing, and the empty Optional leads to a polite message instead of a crash. giveBack("Emma") removes the loan, and the last listing shows all three books again.
find would return null when nothing matched, and every caller would have to remember to check before touching the result, or crash with a NullPointerException. Returning Optional<Book>puts the possibility in the method's type. The caller cannot reach the book without deciding what happens when there is none: isEmpty(), ifPresent, or, in the next step, orElseThrow.A map keeps one value per key, which is exactly what a loan needs: one book, one borrower. Check that you know what happens when the same key is used twice.
import java.util.*; public class Main { public static void main(String[] args) { Map<String, String> loans = new HashMap<>(); loans.put("Dune", "Ravi"); loans.put("Emma", "Asha"); loans.put("Dune", "Asha"); loans.remove("Emma"); System.out.println(loans + " " + loans.size()); }}The library lends Dune twice without checking first. What does the program print?
The second put("Dune", ...)replaces Ravi with Asha without a word of warning. Ravi's loan is simply lost. That is why borrow checks containsKey before it calls put: the map will not stop you, so the method has to.
Step four: failures as exceptions#
The Library above has a quiet flaw: when something goes wrong, it prints a message itself. That ties the logic to the console. A window could not show that message, a test could not check it, and the code that called borrow has no idea it failed. The fix, from exceptions, is to report the failure and let the caller decide what to tell the user.
A failure that belongs to your program deserves an exception of its own, so give the library one. LibraryException extends Exception, which makes it a checked exception: any method that can throw it must say so with throws LibraryException, and any caller must either catch it or pass it on. The compiler will not let anyone forget that borrowing can fail.
import java.util.*; public class Main { public static void main(String[] args) { Library library = new Library(); library.add(new Book("Dune", "Herbert")); library.add(new Book("Emma", "Austen")); String[][] requests = { {"Emma", "Asha"}, {"Emma", "Ravi"}, {"Mistborn", "Ravi"} }; for (String[] request : requests) { String title = request[0]; String who = request[1]; try { library.borrow(title, who); System.out.println(who + " borrowed " + title); } catch (LibraryException e) { System.out.println("Sorry, " + who + ": " + e.getMessage()); } } }} record Book(String title, String author) {} class LibraryException extends Exception { LibraryException(String message) { super(message); }} class Library { private final List<Book> books = new ArrayList<>(); private final Map<String, String> loans = new HashMap<>(); void add(Book book) { books.add(book); } Optional<Book> find(String title) { for (Book b : books) { if (b.title().equalsIgnoreCase(title)) { return Optional.of(b); } } return Optional.empty(); } void borrow(String title, String who) throws LibraryException { Book book = find(title) .orElseThrow(() -> new LibraryException("there is no book called " + title)); String holder = loans.get(book.title()); if (holder != null) { throw new LibraryException(book.title() + " is out with " + holder); } loans.put(book.title(), who); }}It prints Asha borrowed Emma, then Sorry, Ravi: Emma is out with Asha, then Sorry, Ravi: there is no book called Mistborn. Look at borrow now: it prints nothing. orElseThrow either hands back the book inside the Optional or throws the exception the lambda builds. A second throw covers a book that is already out. Every message is decided in main, in the one catch block. The fields are now private too, so nothing outside the class can change the loans behind its back, as in encapsulation.
Key ideaThe class that finds a problem should report it, not decide how to show it. Throw where you find the problem; catch where you can do something about it.
giveBack in the same style as borrow. It should throw a LibraryException saying there is no book called ... when the title is unknown, or ... was not borrowed when the book is not out. Otherwise it removes the loan.Step five: remembering between runs#
So far the library forgets everything the moment the program ends. To remember, it writes the catalogue to a file and reads it back next time, with the Files methods from files with java.nio. The format is as plain as it gets: one line per book, the title and the author separated by a semicolon.
import java.io.IOException;import java.nio.file.Files;import java.nio.file.Path;import java.util.*; public class Main { public static void main(String[] args) throws IOException { Path file = Path.of("library.txt"); Library first = new Library(); first.add(new Book("Dune", "Herbert")); first.add(new Book("Emma", "Austen")); first.save(file); System.out.println("Saved: " + Files.readAllLines(file)); Library second = new Library(); second.load(file); System.out.println("Loaded " + second.size() + " books: " + second.titles()); }} record Book(String title, String author) {} class Library { private final List<Book> books = new ArrayList<>(); void add(Book book) { books.add(book); } int size() { return books.size(); } List<String> titles() { return books.stream().map(Book::title).toList(); } void save(Path file) throws IOException { List<String> lines = new ArrayList<>(); for (Book b : books) { lines.add(b.title() + ";" + b.author()); } Files.write(file, lines); } void load(Path file) throws IOException { for (String line : Files.readAllLines(file)) { String[] parts = line.split(";"); books.add(new Book(parts[0], parts[1])); } }}It prints Saved: [Dune;Herbert, Emma;Austen] and Loaded 2 books: [Dune, Emma], and the Files panel shows library.txt with its two lines. save turns each book into one line and writes them all at once. load does the reverse: it reads every line, splits it at the semicolon, and builds a Bookfrom the two parts. The second library never saw the first one's objects. It got everything from the file, which is exactly what will happen when the real program starts up tomorrow.
Emma by Austen.Tap a line, then tap where it should go. Or use the arrows.
Love; Actually would be split into three parts and come back wrong. Real formats such as CSV and JSON have rules for quoting, so any text survives the round trip. For a first project, a separator you never use in the data is fine, as long as you know why.Step six: the keyboard menu#
The logic is done, and every piece of it has run. The last piece lets a person drive it from the keyboard. Here is the finished program. It starts by loading library.txt, which already holds three books, and the keyboard box below it holds what a user types: look at the shelf, borrow Emma, try two loans that fail, add a book, try a bad return, mistype the menu, then quit. Run it, then change the input and run it again.
import java.io.IOException;import java.nio.file.Files;import java.nio.file.Path;import java.util.*; public class Main { public static void main(String[] args) throws IOException { Path file = Path.of("library.txt"); Library library = new Library(); if (Files.exists(file)) { library.load(file); } Scanner in = new Scanner(System.in); boolean running = true; while (running) { System.out.println("1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit"); System.out.print("> "); String choice = in.nextLine().trim(); try { switch (choice) { case "1" -> System.out.println("On the shelf: " + library.onShelf()); case "2" -> { System.out.print("Title: "); String title = in.nextLine(); System.out.print("Your name: "); String who = in.nextLine(); library.borrow(title, who); System.out.println("Enjoy it, " + who + "!"); } case "3" -> { System.out.print("Title: "); library.giveBack(in.nextLine()); System.out.println("Thanks!"); } case "4" -> { System.out.print("Title: "); String title = in.nextLine(); System.out.print("Author: "); String author = in.nextLine(); library.add(new Book(title, author)); } case "0" -> running = false; default -> System.out.println("Please pick a number from 0 to 4."); } } catch (LibraryException e) { System.out.println("Sorry: " + e.getMessage()); } } library.save(file); System.out.println("Saved " + library.size() + " books. Bye!"); }} record Book(String title, String author) {} class LibraryException extends Exception { LibraryException(String message) { super(message); }} class Library { private final List<Book> books = new ArrayList<>(); private final Map<String, String> loans = new HashMap<>(); // title -> who has it void add(Book book) { books.add(book); } int size() { return books.size(); } Optional<Book> find(String title) { for (Book b : books) { if (b.title().equalsIgnoreCase(title)) { return Optional.of(b); } } return Optional.empty(); } void borrow(String title, String who) throws LibraryException { Book book = find(title) .orElseThrow(() -> new LibraryException("there is no book called " + title)); String holder = loans.get(book.title()); if (holder != null) { throw new LibraryException(book.title() + " is out with " + holder); } loans.put(book.title(), who); } void giveBack(String title) throws LibraryException { Book book = find(title) .orElseThrow(() -> new LibraryException("there is no book called " + title)); if (loans.remove(book.title()) == null) { throw new LibraryException(book.title() + " was not borrowed"); } } List<String> onShelf() { return books.stream() .filter(b -> !loans.containsKey(b.title())) .map(Book::title) .toList(); } void save(Path file) throws IOException { List<String> lines = new ArrayList<>(); for (Book b : books) { lines.add(b.title() + ";" + b.author()); } Files.write(file, lines); } void load(Path file) throws IOException { for (String line : Files.readAllLines(file)) { String[] parts = line.split(";"); books.add(new Book(parts[0], parts[1])); } }}Dune;Herbert Emma;Austen Solaris;Lem
Look at how thin the menu is. Each case reads what it needs and calls one method on library. The switch with arrows, from if-else and switch, needs no break. One try wraps the whole switch, so a failed borrow or return prints its message and the loop simply shows the menu again. And when the user picks 0, the loop ends and the catalogue is saved, Middlemarch included: the Files panel shows four lines now.
Key ideaKeep your logic apart from your input and output. Logic you can test on its own is logic you can trust. The menu is just a steering wheel; the engine is the classes underneath.
What comes next#
This project closes the core of the course, and it is a fair picture of what you can build with the language alone. The phases after it add the things real software leans on.
- Phase 7, concurrency. Doing several things at once, from your first threads to thread pools and virtual threads. A library server answering many users at the same time would need it.
- Phase 8, tools of the trade. Git to keep every version of this project, and Maven and Gradle to build it with libraries and tests the way teams do.
- Phase 9, data and the web. A real database instead of a text file, starting with SQL and JDBC, and then a web service that other programs can talk to, which ends in a second, bigger project.
- Desktop apps with JavaFX. An elective track that puts windows and buttons in front of programs like this one, starting with what a window really is.
The course so far, on one map#
Here is the route through the first six phases, so you can see how each idea sat on the one before it. Every link goes back to where it was taught.
Phase 1: first steps
From an empty file to collections of values. setup and the JVM, variables and types, operators and casting, if-else and switch, loops, arrays, ArrayList, two-dimensional arrays and reading errors.
Phase 2: objects
Where Library and the habit of designing classes came from. why objects, classes and objects, methods, constructors, encapsulation, this and references, inheritance, overriding and super, polymorphism, OOP practice, strings, string practice and testing with JUnit.
Phase 3: abstraction and design
Where Book became a record. abstract classes, interfaces, choosing between them, static and final, inner classes, enums and lambdas, records, sealed types and patterns and design principles.
Phase 4: collections and algorithms
Where the catalogue and the loans came from. wrapper classes, generics, the collections family, HashMap and HashSet, object equality, comparing and sorting, stacks and queues, linked lists, recursion, Big O, searching and sorting and building your own.
Phase 5: errors, files and the standard library
Where LibraryException, library.txt and the menu came from. packages and imports, Scanner and the console, reading files, writing files, exception handling, files with java.nio, dates and times, money with BigDecimal and regular expressions.
Phase 6: functional and modern Java
Where onShelf and find got their shape. streams, collectors, Optional, modern syntax and annotations and tooling.
Try it
Answer each one in your head or on paper first, then open the answer.
library.txt. Is Emma on the shelf? Why?borrow throw a LibraryException instead of printing “Sorry” itself, as the version in step three did?library.txt by hand and adds a line with no semicolon: Middlemarch. What happens when the program starts, and how could load protect itself?emma in lower case, yet the program says Enjoy it, Asha! and later reports Emma is out with Asha with a capital E. Which line makes that work?Practice
Work through these on your own. Each one builds on this page.
countOnShelf() to Library that returns how many books are not on loan, using a stream and count(). Add a menu option that prints it, and check that the number drops by one after a borrow.Map<String, Loan>, where record Loan(String who, LocalDate due), and add a menu option that lists every loan that is overdue today.loans.txt, one title;who line each, and load them at start-up. What should happen if loans.txt names a book that is no longer in the catalogue?Library, as in testing with JUnit: one that borrows a book and checks it has left the shelf, one that checks borrowing an unknown title throws LibraryException with assertThrows, and one that checks a second borrow of the same book fails.Spotted a mistake, or a part that did not make sense? Report it by email