Learn Java

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

By the end of this lesson
  • 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 List for the catalogue, a Map for 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.
From before
Every piece of this project has a lesson of its own: classes and objects, records, ArrayList, HashMap, Optional, streams, exceptions, files with java.nio and Scanner. Nothing here is new. That is the point.

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 idea

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

Design before you type
The most common beginner mistake is to start typing at once. Five minutes with a pen, naming your classes and what each one holds and does, saves an hour of confusion. We do that first, on purpose.

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.

Book (one entry)
Library (the whole collection)
Has a title and an author.
Has a List of books, the catalogue.
Never changes once made. Dune is always Dune by Herbert.
Has a Map from a title to the person who has it, the loans.
So it can be a record.
Can 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.

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Book dune = new Book("Dune", "Herbert");
4 Book copy = new Book("Dune", "Herbert");
5
6 System.out.println(dune.title() + " by " + dune.author());
7 System.out.println(dune);
8 System.out.println(dune.equals(copy));
9 System.out.println(dune == copy);
10 }
11}
12
13record Book(String title, String author) {}
Prints
›Dune by Herbert
›Book[title=Dune, author=Herbert]
›true
›false

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.

Quick check
A list holds one book, added with 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. 1add: put a book in the list. The list grows by itself, so there is no size to worry about.
  2. 2find: walk the list and return the book whose title matches, ignoring upper and lower case. If there is none, return an empty Optional rather than null.
  3. 3borrow: find the book; if it is not already in loans, put it there with the borrower's name.
  4. 4giveBack: find the book and remove its loan.
  5. 5onShelf: the titles of every book that is not in loans, 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.

Main.java
1import java.util.*;
2
3public class Main {
4 public static void main(String[] args) {
5 Library library = new Library();
6 library.add(new Book("Dune", "Herbert"));
7 library.add(new Book("Emma", "Austen"));
8 library.add(new Book("Solaris", "Lem"));
9 System.out.println("On the shelf: " + library.onShelf());
10
11 library.borrow("Emma", "Asha");
12 library.borrow("Mistborn", "Ravi");
13 System.out.println("On the shelf: " + library.onShelf());
14 System.out.println("Out: " + library.loans);
15
16 library.giveBack("Emma");
17 System.out.println("On the shelf: " + library.onShelf());
18 }
19}
20
21record Book(String title, String author) {}
22
23class Library {
24 List<Book> books = new ArrayList<>();
25 Map<String, String> loans = new HashMap<>(); // title -> who has it
26
27 void add(Book book) {
28 books.add(book);
29 }
30
31 Optional<Book> find(String title) {
32 for (Book b : books) {
33 if (b.title().equalsIgnoreCase(title)) {
34 return Optional.of(b);
35 }
36 }
37 return Optional.empty();
38 }
39
40 void borrow(String title, String who) {
41 Optional<Book> found = find(title);
42 if (found.isEmpty()) {
43 System.out.println("No book called " + title);
44 } else if (loans.containsKey(found.get().title())) {
45 System.out.println(title + " is already out");
46 } else {
47 loans.put(found.get().title(), who);
48 System.out.println(who + " borrowed " + found.get().title());
49 }
50 }
51
52 void giveBack(String title) {
53 find(title).ifPresent(b -> {
54 loans.remove(b.title());
55 System.out.println(b.title() + " is back");
56 });
57 }
58
59 List<String> onShelf() {
60 return books.stream()
61 .filter(b -> !loans.containsKey(b.title()))
62 .map(Book::title)
63 .toList();
64 }
65}
Prints
›On the shelf: [Dune, Emma, Solaris]
›Asha borrowed Emma
›No book called Mistborn
›On the shelf: [Dune, Solaris]
›Out: {Emma=Asha}
›Emma is back
›On the shelf: [Dune, Emma, Solaris]

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.

Optional makes “not found” impossible to forget
An older version of 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.

Predict the outputLoans.java
1import java.util.*;
2
3public class Main {
4 public static void main(String[] args) {
5 Map<String, String> loans = new HashMap<>();
6 loans.put("Dune", "Ravi");
7 loans.put("Emma", "Asha");
8 loans.put("Dune", "Asha");
9 loans.remove("Emma");
10 System.out.println(loans + " " + loans.size());
11 }
12}

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.

Main.java
1import java.util.*;
2
3public class Main {
4 public static void main(String[] args) {
5 Library library = new Library();
6 library.add(new Book("Dune", "Herbert"));
7 library.add(new Book("Emma", "Austen"));
8
9 String[][] requests = { {"Emma", "Asha"}, {"Emma", "Ravi"}, {"Mistborn", "Ravi"} };
10 for (String[] request : requests) {
11 String title = request[0];
12 String who = request[1];
13 try {
14 library.borrow(title, who);
15 System.out.println(who + " borrowed " + title);
16 } catch (LibraryException e) {
17 System.out.println("Sorry, " + who + ": " + e.getMessage());
18 }
19 }
20 }
21}
22
23record Book(String title, String author) {}
24
25class LibraryException extends Exception {
26 LibraryException(String message) {
27 super(message);
28 }
29}
30
31class Library {
32 private final List<Book> books = new ArrayList<>();
33 private final Map<String, String> loans = new HashMap<>();
34
35 void add(Book book) {
36 books.add(book);
37 }
38
39 Optional<Book> find(String title) {
40 for (Book b : books) {
41 if (b.title().equalsIgnoreCase(title)) {
42 return Optional.of(b);
43 }
44 }
45 return Optional.empty();
46 }
47
48 void borrow(String title, String who) throws LibraryException {
49 Book book = find(title)
50 .orElseThrow(() -> new LibraryException("there is no book called " + title));
51 String holder = loans.get(book.title());
52 if (holder != null) {
53 throw new LibraryException(book.title() + " is out with " + holder);
54 }
55 loans.put(book.title(), who);
56 }
57}
Prints
›Asha borrowed Emma
›Sorry, Ravi: Emma is out with Asha
›Sorry, Ravi: there is no book called Mistborn

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 idea

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

ExerciseGiveBack.java
Write 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.

Main.java
1import java.io.IOException;
2import java.nio.file.Files;
3import java.nio.file.Path;
4import java.util.*;
5
6public class Main {
7 public static void main(String[] args) throws IOException {
8 Path file = Path.of("library.txt");
9
10 Library first = new Library();
11 first.add(new Book("Dune", "Herbert"));
12 first.add(new Book("Emma", "Austen"));
13 first.save(file);
14 System.out.println("Saved: " + Files.readAllLines(file));
15
16 Library second = new Library();
17 second.load(file);
18 System.out.println("Loaded " + second.size() + " books: " + second.titles());
19 }
20}
21
22record Book(String title, String author) {}
23
24class Library {
25 private final List<Book> books = new ArrayList<>();
26
27 void add(Book book) {
28 books.add(book);
29 }
30
31 int size() {
32 return books.size();
33 }
34
35 List<String> titles() {
36 return books.stream().map(Book::title).toList();
37 }
38
39 void save(Path file) throws IOException {
40 List<String> lines = new ArrayList<>();
41 for (Book b : books) {
42 lines.add(b.title() + ";" + b.author());
43 }
44 Files.write(file, lines);
45 }
46
47 void load(Path file) throws IOException {
48 for (String line : Files.readAllLines(file)) {
49 String[] parts = line.split(";");
50 books.add(new Book(parts[0], parts[1]));
51 }
52 }
53}
Prints
›Saved: [Dune;Herbert, Emma;Austen]
›Loaded 2 books: [Dune, Emma]

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.

Put the lines in orderOneLine.java
Put the lines in order so the program turns one saved line back into a book and prints Emma by Austen.

Tap a line, then tap where it should go. Or use the arrows.

A simple format has simple limits
The semicolon works only because no title in this catalogue contains one. A book called 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.

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.

Main.java
1import java.io.IOException;
2import java.nio.file.Files;
3import java.nio.file.Path;
4import java.util.*;
5
6public class Main {
7 public static void main(String[] args) throws IOException {
8 Path file = Path.of("library.txt");
9 Library library = new Library();
10 if (Files.exists(file)) {
11 library.load(file);
12 }
13
14 Scanner in = new Scanner(System.in);
15 boolean running = true;
16 while (running) {
17 System.out.println("1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit");
18 System.out.print("> ");
19 String choice = in.nextLine().trim();
20 try {
21 switch (choice) {
22 case "1" -> System.out.println("On the shelf: " + library.onShelf());
23 case "2" -> {
24 System.out.print("Title: ");
25 String title = in.nextLine();
26 System.out.print("Your name: ");
27 String who = in.nextLine();
28 library.borrow(title, who);
29 System.out.println("Enjoy it, " + who + "!");
30 }
31 case "3" -> {
32 System.out.print("Title: ");
33 library.giveBack(in.nextLine());
34 System.out.println("Thanks!");
35 }
36 case "4" -> {
37 System.out.print("Title: ");
38 String title = in.nextLine();
39 System.out.print("Author: ");
40 String author = in.nextLine();
41 library.add(new Book(title, author));
42 }
43 case "0" -> running = false;
44 default -> System.out.println("Please pick a number from 0 to 4.");
45 }
46 } catch (LibraryException e) {
47 System.out.println("Sorry: " + e.getMessage());
48 }
49 }
50
51 library.save(file);
52 System.out.println("Saved " + library.size() + " books. Bye!");
53 }
54}
55
56record Book(String title, String author) {}
57
58class LibraryException extends Exception {
59 LibraryException(String message) {
60 super(message);
61 }
62}
63
64class Library {
65 private final List<Book> books = new ArrayList<>();
66 private final Map<String, String> loans = new HashMap<>(); // title -> who has it
67
68 void add(Book book) {
69 books.add(book);
70 }
71
72 int size() {
73 return books.size();
74 }
75
76 Optional<Book> find(String title) {
77 for (Book b : books) {
78 if (b.title().equalsIgnoreCase(title)) {
79 return Optional.of(b);
80 }
81 }
82 return Optional.empty();
83 }
84
85 void borrow(String title, String who) throws LibraryException {
86 Book book = find(title)
87 .orElseThrow(() -> new LibraryException("there is no book called " + title));
88 String holder = loans.get(book.title());
89 if (holder != null) {
90 throw new LibraryException(book.title() + " is out with " + holder);
91 }
92 loans.put(book.title(), who);
93 }
94
95 void giveBack(String title) throws LibraryException {
96 Book book = find(title)
97 .orElseThrow(() -> new LibraryException("there is no book called " + title));
98 if (loans.remove(book.title()) == null) {
99 throw new LibraryException(book.title() + " was not borrowed");
100 }
101 }
102
103 List<String> onShelf() {
104 return books.stream()
105 .filter(b -> !loans.containsKey(b.title()))
106 .map(Book::title)
107 .toList();
108 }
109
110 void save(Path file) throws IOException {
111 List<String> lines = new ArrayList<>();
112 for (Book b : books) {
113 lines.add(b.title() + ";" + b.author());
114 }
115 Files.write(file, lines);
116 }
117
118 void load(Path file) throws IOException {
119 for (String line : Files.readAllLines(file)) {
120 String[] parts = line.split(";");
121 books.add(new Book(parts[0], parts[1]));
122 }
123 }
124}
Files before the program runs
library.txt
Dune;Herbert
Emma;Austen
Solaris;Lem
Prints
›1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit
›> 1
›On the shelf: [Dune, Emma, Solaris]
›1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit
›> 2
›Title: emma
›Your name: Asha
›Enjoy it, Asha!
›1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit
›> 2
›Title: Emma
›Your name: Ravi
›Sorry: Emma is out with Asha
›1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit
›> 2
›Title: Mistborn
›Your name: Ravi
›Sorry: there is no book called Mistborn
›1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit
›> 1
›On the shelf: [Dune, Solaris]
›1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit
›> 4
›Title: Middlemarch
›Author: Eliot
›1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit
›> 3
›Title: Dune
›Sorry: Dune was not borrowed
›1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit
›> 9
›Please pick a number from 0 to 4.
›1) Shelf 2) Borrow 3) Return 4) Add 0) Save and quit
›> 0Saved 4 books. Bye!

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 idea

Keep 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.
New libraries, not a new language
Databases, web services and windows feel like new worlds, but look closely and they are the same Java: classes, objects, methods, interfaces, exceptions and lambdas. Everything you used today is what lets you read them at all.

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.

Keep building
The fastest way to keep all this is to keep building. Give the library due dates, or members, or a search by author. Then pick a small program of your own and build it the same way: design on paper, run the core early, add the menu last. That is what a working engineer actually does.

Try it

Answer each one in your head or on paper first, then open the answer.

1
A running club wants a program that tracks its members and the races each one has entered. Before any code, name the classes or records and, for each, what it holds and one thing it does. Where would “who entered which race” live?
2
In the finished program, the user borrows Emma and then quits. The program is started again with the same library.txt. Is Emma on the shelf? Why?
3
Why does borrow throw a LibraryException instead of printing “Sorry” itself, as the version in step three did?
4
Someone edits library.txt by hand and adds a line with no semicolon: Middlemarch. What happens when the program starts, and how could load protect itself?
5
In the run above, the user types 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.

1
Add a method 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.
2
Give every loan a due date, two weeks after the day it was made. Replace the loans map with a Map<String, Loan>, where record Loan(String who, LocalDate due), and add a menu option that lists every loan that is overdue today.
3
Make the loans survive a restart too. Save them to a second file, 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?
4
Write JUnit tests for 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.
5
Design, on paper only, a small program of your own: a playlist, a to-do list, a shop's stock. Write down the records and classes, their fields and their methods, and where each piece of data lives, before writing a line of Java. Then build it in the same order as this lesson: the core first, run at every step, the menu last.

Spotted a mistake, or a part that did not make sense? Report it by email