Skip to content
Ameen Abozaid
PublishedBEG-037-L04

Demo: making findById return Optional

  • Lesson 4 of 4
  • 11 min read
  • Beginner
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 findById into one that returns Optional<T> using Optional.ofNullable.
  • Use the compiler's errors to find every caller that must change.
  • Update callers with map(...).orElse(...), orElseThrow(...), and ifPresentOrElse(...).
  • 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:

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

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

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

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

text
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 wantsUse
A harmless default for displayrepo.findById(id).map(Contact::name).orElse("unknown")
The contact, or a clear failurerepo.findById(id).orElseThrow(() -> new NoSuchElementException("No contact " + id))
To act only when found, and do something else when notrepo.findById(id).ifPresentOrElse(found -> ..., () -> ...)

Here is a Main2 that uses all three:

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

text
Ada / unknown
ada@example.com
contact 9 not found

Step 5: the planned mistake, an unchecked get()

A caller in a hurry fixes the compile error with .get():

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

text
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

java
int id = 9;
System.out.println(repo.findById(id).orElseThrow(
    () -> new NoSuchElementException("No contact " + id)).name());
text
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:

java
// 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:

java
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):

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

text
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 getById convenience in Example 3 is acceptable because its policy is the single most common one, and it still builds on findById.
  • 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 an Optional is 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.

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

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

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

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

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

  1. Write a Main with three callers: one that prints the title or "unknown product", one that requires the product and throws NoSuchElementException("No product " + sku), and one that uses ifPresentOrElse.
  2. Deliberately write repo.findById("X1").get() once, run it with a missing sku, and copy the real message. Then fix it.
  3. Search your folder for .get() and confirm that none is an unchecked Optional.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 findById to return Optional.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. orElseThrow with 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 an Optional without 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

  1. Which factory method belongs inside findById and why?
  2. What does the compiler say when a caller still assigns findById to a Contact?
  3. Which method gives a descriptive failure for a missing id?
  4. Why is if (repo.findById(id) != null) wrong after the change?
  5. What is the lab's acceptance rule?
Show answers
  1. Optional.ofNullable, because Map.get returns null for a missing key and Optional.of(null) would throw.
  2. incompatible types: Optional<Contact> cannot be converted to Contact.
  3. orElseThrow with a supplier that builds an exception whose message names the id.
  4. An Optional is never null, so the test is always true. Use isPresent(), ifPresent, or map.
  5. No .get() without a check.

References

  • Oracle, Java SE 21 API: Optional. Supports ofNullable returning an empty Optional for null, of(null) throwing NullPointerException, get() throwing NoSuchElementException, orElseThrow(Supplier), and ifPresentOrElse.
  • 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.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Type to search levels, phases, modules and lessons.