Demo: making findById return Optional
On This Lesson 11 topics
Lesson Overview
This is a hands-on lesson. You will take the generic in-memory repository from the generics module, whose findById returns null for an unknown id, and convert it so that findById returns Optional<T>. Then you will update the callers, run the planned mistake (an unchecked get() on a missing id), and fix it with a message that helps. This is the core of the module lab, "Safe lookups".
Think of it as a renovation. The repository is the house; findById is a door that used to open onto nothing without any warning. We put a sign on the door (the Optional type), so that everyone walking through has to read it.
Why this matters for backend work: "find by id" is the most common repository operation, and Spring Data, which you will meet later, gives its find-by-id method an Optional result. Making this change by hand now means the real thing will look familiar.
Everything below was compiled and run with JDK 21. Use any editor or IDE you like. Menus differ by product, so the steps use plain files and the javac and java commands, run in a terminal from the folder holding the files. If your IDE compiles for you, the code is the same. We compile with javac -g so that the NullPointerException message names the variable.
This lesson uses the repository from BEG-031 (lesson "Demo: building a generic in-memory repository"), records as quick data holders, and everything from the previous three lessons.
Learning Objectives
By the end of this lesson you will be able to:
- Change a null-returning
findByIdinto one that returnsOptional<T>usingOptional.ofNullable. - Use the compiler's errors to find every caller that must change.
- Update callers with
map(...).orElse(...),orElseThrow(...), andifPresentOrElse(...). - Reproduce the unchecked-
get()failure and fix it with a descriptive exception. - Decide for each caller whether a missing id means "use a default" or "fail".
- Check a code base for any
.get()that is not protected by a check.
Written Explanation
Step 1: the starting point
The repository from the generics module has a findById that returns whatever Map.get returns:
public T findById(ID id) {
return store.get(id); // null when the id is unknown
}Files used throughout (in one folder): Contact.java with public record Contact(String name, String email) { }, InMemoryRepository.java with save, findById, findAll, delete as before, and a Main.java that uses them:
public class Main {
public static void main(String[] args) {
InMemoryRepository<Contact, Integer> repo = new InMemoryRepository<>();
repo.save(1, new Contact("Ada", "ada@example.com"));
Contact c = repo.findById(1);
System.out.println(c.name());
Contact missing = repo.findById(9);
System.out.println(missing.name());
}
}Compile with javac -g *.java and run java Main:
Ada
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Contact.name()" because "missing" is null
at Main.main(Main.java:10)That is the problem from the first lesson. Nothing in the code warned us.
Step 2: change the return type
Two edits in InMemoryRepository.java: add import java.util.Optional;, and change the method:
public Optional<T> findById(ID id) {
return Optional.ofNullable(store.get(id));
}We use ofNullable, not of, because Map.get returns null for a missing key, and Optional.of(null) would throw inside the repository itself. The repository's other methods stay unchanged: findAll already returns a list, which is empty when nothing is stored (the rule from the previous lesson), and delete returns a boolean.
Step 3: let the compiler find the callers
Compile again without touching Main:
Main.java:6: error: incompatible types: Optional<Contact> cannot be converted to Contact
Contact c = repo.findById(1);
^The compiler lists every caller that still treats the result as a plain Contact. (In our run it stopped at the first file with problems; fix the errors it shows and compile again until it is quiet.) This is the main benefit of the change: you cannot forget a caller, because the old style no longer compiles. In a large code base, the list of errors is your to-do list.
Repeat this loop: compile, read the error, fix the caller, compile again.
Step 4: choose a policy for each caller
For each broken line, ask what a missing id means there. Do not mechanically add .get(): that would hide the question.
| Caller wants | Use |
|---|---|
| A harmless default for display | repo.findById(id).map(Contact::name).orElse("unknown") |
| The contact, or a clear failure | repo.findById(id).orElseThrow(() -> new NoSuchElementException("No contact " + id)) |
| To act only when found, and do something else when not | repo.findById(id).ifPresentOrElse(found -> ..., () -> ...) |
Here is a Main2 that uses all three:
public class Main2 {
public static void main(String[] args) {
InMemoryRepository<Contact, Integer> repo = new InMemoryRepository<>();
repo.save(1, new Contact("Ada", "ada@example.com"));
String n1 = repo.findById(1).map(Contact::name).orElse("unknown");
String n2 = repo.findById(9).map(Contact::name).orElse("unknown");
System.out.println(n1 + " / " + n2);
Contact c = repo.findById(1).orElseThrow(
() -> new java.util.NoSuchElementException("No contact 1"));
System.out.println(c.email());
repo.findById(9).ifPresentOrElse(
x -> System.out.println("found " + x.name()),
() -> System.out.println("contact 9 not found"));
}
}Output:
Ada / unknown
ada@example.com
contact 9 not foundStep 5: the planned mistake, an unchecked get()
A caller in a hurry fixes the compile error with .get():
System.out.println(repo.findById(1).get().name());
System.out.println(repo.findById(9).get().name());It compiles, and for id 1 it even works. For the missing id:
Ada
Exception in thread "main" java.util.NoSuchElementException: No value present
at java.base/java.util.Optional.get(Optional.java:143)
at Main3.main(Main3.java:7)The compiler is satisfied, the crash has a new name, and the message No value present does not say what was missing. This is the lab's acceptance rule in action: no .get() without a check.
Step 6: fix it with a helpful message
int id = 9;
System.out.println(repo.findById(id).orElseThrow(
() -> new NoSuchElementException("No contact " + id)).name());Ada
Exception in thread "main" java.util.NoSuchElementException: No contact 9
at Main4.lambda$main$1(Main4.java:12)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at Main4.main(Main4.java:11)Read the message: it names the entity and the id. In a log file at 3 a.m. that is the difference between a five-second diagnosis and a long search. (The id variable is effectively final, meaning it is assigned once, which a lambda requires.)
Step 7: an audit
To finish, search your project for .get() on Optional values. In an IDE this is a text search for .get(), and menus differ by product. A plain search tool also works (for example grep -n "\.get()" in many terminals). Look at every hit: calls on Optional must be replaced or sit right behind a check. Calls to Supplier.get() or List.get(i) are different methods and are fine.
Examples
Example 1: only the signature changes (simple)
Before and after, side by side:
// before
public T findById(ID id) { return store.get(id); }
// after
public Optional<T> findById(ID id) { return Optional.ofNullable(store.get(id)); }The data in the map did not change and neither did save. Only the promise to the caller changed.
Example 2: three callers, three policies (practical)
Using the Main2 output above: a display line wants a default (unknown), a business operation wants a contact or a failure (orElseThrow), and a notification wants one action when found and another when not (ifPresentOrElse). The same findById serves all three. The repository does not decide what "missing" means; the caller does.
Example 3: an extra repository method built on the same idea (realistic)
A repository often needs more than "by id". Here is a small version (a separate Repo class with an Event(String title, int attendees) record) that adds a throwing variant and a search by test:
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.function.Predicate;
public class Repo<T, ID> {
private final Map<ID, T> store = new LinkedHashMap<>();
public void save(ID id, T item) { store.put(id, item); }
public Optional<T> findById(ID id) {
return Optional.ofNullable(store.get(id));
}
public T getById(ID id) {
return findById(id).orElseThrow(
() -> new IllegalArgumentException("No item with id " + id));
}
public Optional<T> findFirst(Predicate<T> test) {
return store.values().stream().filter(test).findFirst();
}
}With events saved under "demo" (40 attendees) and "meet" (12 attendees):
System.out.println(events.findById("demo").isPresent());
System.out.println(events.findById("zzz").isPresent());
System.out.println(events.findById("meet").map(Event::attendees).orElse(0));
System.out.println(events.findFirst(e -> e.attendees() > 100).map(Event::title).orElse("none"));
System.out.println(events.findFirst(e -> e.attendees() > 20));
System.out.println(events.getById("zzz"));Output:
true
false
12
none
Optional[Event[title=Demo Day, attendees=40]]
Exception in thread "main" java.lang.IllegalArgumentException: No item with id zzz
at Repo.lambda$getById$0(Repo.java:17)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at Repo.getById(Repo.java:16)
at Main.main(Main.java:14)findFirst is the stream operation from the streams module, and its Optional result flows straight out of the repository method. The throwing getById is built on findById, so there is a single place where the lookup happens.
Backend Insight
The pattern you just built is the standard shape in layered backends: the repository returns Optional, and a service method chooses the policy. A service that must have the entity calls orElseThrow with a meaningful exception; a web layer later maps that exception to a "not found" response. A service that treats absence as normal returns a default or does nothing.
Two habits make this scale:
- Keep the repository free of policy. It reports "found" or "not found" and lets callers decide. The
getByIdconvenience in Example 3 is acceptable because its policy is the single most common one, and it still builds onfindById. - Put the identifying information in every exception message: the entity name and the id. When a production error arrives, that text is often the only clue.
Also watch out for the opposite trap: converting a repository method to Optional and then calling .get() at every call site gives you all of the work and none of the safety.
Common Mistakes
Mistake 1: .get() to silence the compiler
- Incorrect:
Contact c = repo.findById(id).get(); - Correct:
Contact c = repo.findById(id).orElseThrow(() -> new NoSuchElementException("No contact " + id));
Mistake 2: Optional.of inside the repository
Optional.of(store.get(id)) throws NullPointerException for a missing key, inside the repository, before any caller sees an Optional. Use ofNullable.
Mistake 3: comparing the result with null
- Incorrect:
if (repo.findById(id) != null) { ... }. It compiles, since anOptionalis an object, and is then true for every call, including the empty box. - Correct:
if (repo.findById(id).isPresent()) { ... }, or better,ifPresent/map.
Mistake 4: forgetting to rebuild everything
If you change a class in one file and recompile only that file, old compiled files may still use the old signature and fail at runtime. Recompile all files (javac -g *.java), or let your IDE do a full build.
Exercises
Try each exercise yourself before opening the solutions. Solutions were compiled and run with JDK 21.
Level A: Foundation
A1. Why does findById use Optional.ofNullable(store.get(id)) and not Optional.of(store.get(id))?
Show solution A1
A1. Map.get returns null for a missing key. Optional.of(null) throws NullPointerException immediately, inside the repository. ofNullable returns an empty Optional instead.
A2. After the change, the line Contact c = repo.findById(1); no longer compiles. What does the compiler say, and why is that useful?
Show solution A2
A2. incompatible types: Optional<Contact> cannot be converted to Contact. It is useful because the compiler now lists every caller that still assumes the result always exists, so none can be forgotten.
A3. Which Optional method fits each need: (a) show "unknown" when missing, (b) stop with an error naming the id, (c) print a message in either case?
Show solution A3
A3. (a) map(...).orElse("unknown"), or orElse after a map. (b) orElseThrow(() -> new SomeException("No contact " + id)). (c) ifPresentOrElse(...).
Level B: Application
B1. Using Repo<Event, String> from Example 3, write one statement that prints the number of attendees of event "meet", or 0 if the event does not exist.
Show solution B1
B1.
System.out.println(events.findById("meet").map(Event::attendees).orElse(0));It prints 12. For an unknown id it prints 0.
B2. An old caller says if (repo.findById(7) != null) { System.out.println("exists"); }. Explain what is wrong after the change and rewrite it.
Show solution B2
B2. The method returns an Optional, which is never null, so the condition is always true, even for a missing id, and the program prints "exists" wrongly. Rewrite: if (repo.findById(7).isPresent()) { ... }.
B3. A teammate "fixed" a caller with repo.findById(id).get(). Rewrite it so it throws IllegalStateException with the message Contact <id> missing.
Show solution B3
B3.
repo.findById(id).orElseThrow(
() -> new IllegalStateException("Contact " + id + " missing"));Level C: Challenge
C1. Add a method boolean exists(ID id) to Repo that does not use get(), and a method Optional<T> findFirst(Predicate<T> test) if you do not have it. Show a call that finds the first event with more than 20 attendees.
Show solution C1
C1.
public boolean exists(ID id) {
return findById(id).isPresent();
}findFirst is as in Example 3. With the two events used there, events.findFirst(e -> e.attendees() > 20) returns Optional[Event[title=Demo Day, attendees=40]].
C2. Audit this snippet and list every problem against this module's rules, then fix it:
Optional<Contact> found = repo.findById(id);
if (found == null) { return "none"; }
return found.get().name();Show solution C2
C2. Problems: (1) found == null is pointless, since an Optional should never be null, and the repository never returns null; (2) found.get() is unchecked and throws NoSuchElementException for an empty box, which the first check does not prevent; (3) the whole snippet is wordy. Fix:
return repo.findById(id).map(Contact::name).orElse("none");Mini Challenge
Do the lab steps on your own types. Create a Product record (String sku, String title, double price) and a repository InMemoryRepository<Product, String> with findById returning Optional<Product>. Then:
- Write a
Mainwith three callers: one that prints the title or"unknown product", one that requires the product and throwsNoSuchElementException("No product " + sku), and one that usesifPresentOrElse. - Deliberately write
repo.findById("X1").get()once, run it with a missing sku, and copy the real message. Then fix it. - Search your folder for
.get()and confirm that none is an uncheckedOptional.get().
Check your work: the code compiles with javac -g *.java, every missing-sku case produces a message that names the sku, and no Optional is compared with null.
Summary & Next Lesson
Key takeaways
- Changing
findByIdto returnOptional.ofNullable(store.get(id))moves "might be missing" into the type. - The compiler lists every caller that must change. Fix each by choosing a policy: default, failure, or action.
- A bare
get()just renames the crash.orElseThrowwith a message that names the entity and id is the safe, diagnosable alternative. - The repository reports found or not found, and callers decide what that means.
- Finish with an audit: no
.get()on anOptionalwithout a check.
Next lesson
This was the last lesson of the Optional module. Before moving on, complete the lab "Safe lookups" on your own repository. The next module, BEG-038, begins with the lesson "Stack and heap: a picture", which explains where variables and objects live while a program runs.
Self-check
- Which factory method belongs inside
findByIdand why? - What does the compiler say when a caller still assigns
findByIdto aContact? - Which method gives a descriptive failure for a missing id?
- Why is
if (repo.findById(id) != null)wrong after the change? - What is the lab's acceptance rule?
Show answers
Optional.ofNullable, becauseMap.getreturnsnullfor a missing key andOptional.of(null)would throw.incompatible types: Optional<Contact> cannot be converted to Contact.orElseThrowwith a supplier that builds an exception whose message names the id.- An
Optionalis nevernull, so the test is always true. UseisPresent(),ifPresent, ormap. - No
.get()without a check.
References
- Oracle, Java SE 21 API:
Optional. SupportsofNullablereturning an emptyOptionalfornull,of(null)throwingNullPointerException,get()throwingNoSuchElementException,orElseThrow(Supplier), andifPresentOrElse. - Not verified from a fetched page: that Spring Data's find-by-id method returns an
Optional(taken from the course spec, which names Spring Data return types as the career link; the Spring documentation was not fetched).
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.