On This Page
The end result: a booking you can't build wrong
Here's the code you'll end up writing, and what it prints:
Booking standup = Booking.builder()
.room("Fuji")
.organizer("priya")
.from(LocalDateTime.of(2026, 11, 2, 9, 30))
.until(LocalDateTime.of(2026, 11, 2, 9, 45))
.title("Daily standup")
.attendee("sam")
.attendee("lee")
.build();
Booking[Fuji, Daily standup, 2026-11-02T09:30 -> 2026-11-02T09:45, organizer=priya, attendees=[sam, lee], projector=false]
length: PT15M
That's a meeting-room booking for a small office system, and it's the example I'll use to show how to implement the builder pattern in Java properly: an immutable object, a nested builder, validation that runs once at the end, a toBuilder() for "same booking, different room", and a step builder that makes the compiler refuse an incomplete booking. If you've ever stared at new Booking("Fuji", "priya", start, end, "Daily standup", null, false) and wondered which argument was which, this is for you.
You should be comfortable with classes, List and java.time. Nothing else.
The code itself came together quickly. The part that deserved the time was a bug I made on purpose afterwards: a builder reused for two bookings, where the first booking quietly grew an attendee. It's the first entry in the troubleshooting section.
Before you start
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| javac | 21.0.12.1 | javac -version |
| Gradle (optional) | 8.14.3 | gradle --version |
Anything from Java 16 up compiles this code, because equals uses pattern matching for instanceof. If you're on 11 or earlier, the troubleshooting section has the exact error and the one-line change.
I didn't use JUnit this time. My build environment couldn't reach Maven Central on the day I wrote this, so the tests are a plain Java class with assertions, and I ran every one. The JUnit conversion is covered in the going-further section, and I haven't printed any JUnit version because I didn't verify one.
Get the skeleton running
I used the command line as the primary path, since it shows exactly which files exist.
mkdir -p room-booking/src/main/java/com/example/booking
mkdir -p room-booking/src/test/java/com/example/booking
cd room-booking
The tree you're aiming for:
room-booking/
└── src/
├── main/java/com/example/booking/
│ ├── App.java
│ ├── Booking.java
│ └── StepBooking.java
└── test/java/com/example/booking/
└── BookingChecks.java
With Gradle
If you'd rather have a build file, add these two next to src. I ran gradle --offline -q run against them and got the same output as the plain javac path.
settings.gradle.kts
rootProject.name = "room-booking"
build.gradle.kts
plugins {
application
}
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
application {
mainClass = "com.example.booking.App"
}
The toolchain block makes Gradle insist on a JDK 21 even when your shell's default is something older. Run it with gradle run.
In IntelliJ IDEA
Choose File → New → Project, pick Java, set the JDK to 21, and keep the build system as IntelliJ (or choose Gradle and Kotlin DSL to match the file above). Then create the package com.example.booking under src. I couldn't click through this myself, so the menu path comes from JetBrains' documentation. It's not executed output.
In Eclipse
Choose File → New → Java Project, set the execution environment to JavaSE-21, then right-click src and choose New → Package. Same caveat: verified from the docs, not run.
With Maven or Docker
You don't need either here. The project has no dependencies and no external services, so a pom.xml would only repeat the Gradle file above, and there's nothing to containerise.
How to implement the builder pattern in Java, step by step
The pattern has one job. It moves object construction out of a constructor with a long argument list and into a separate object with named, optional-friendly methods. The finished object stays immutable.
Step 1: Write the Booking class with a private constructor
Goal: an immutable class that can only be created through its builder.
src/main/java/com/example/booking/Booking.java
package com.example.booking;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class Booking {
private final String room;
private final String organizer;
private final LocalDateTime start;
private final LocalDateTime end;
private final String title;
private final List<String> attendees;
private final boolean needsProjector;
// Private: the only way in is the builder.
private Booking(Builder b) {
this.room = b.room;
this.organizer = b.organizer;
this.start = b.start;
this.end = b.end;
this.title = b.title;
this.attendees = List.copyOf(b.attendees); // copy, so later builder changes can't leak in
this.needsProjector = b.needsProjector;
}
public static Builder builder() {
return new Builder();
}
// Start a new builder pre-filled with this booking's values.
public Builder toBuilder() {
Builder b = new Builder();
b.room = room;
b.organizer = organizer;
b.start = start;
b.end = end;
b.title = title;
b.attendees = new ArrayList<>(attendees);
b.needsProjector = needsProjector;
return b;
}
public String room() { return room; }
public String organizer() { return organizer; }
public LocalDateTime start() { return start; }
public LocalDateTime end() { return end; }
public String title() { return title; }
public List<String> attendees() { return attendees; }
public boolean needsProjector() { return needsProjector; }
public Duration length() { return Duration.between(start, end); }
@Override
public boolean equals(Object o) {
return o instanceof Booking other
&& room.equals(other.room)
&& organizer.equals(other.organizer)
&& start.equals(other.start)
&& end.equals(other.end)
&& title.equals(other.title)
&& attendees.equals(other.attendees)
&& needsProjector == other.needsProjector;
}
@Override
public int hashCode() {
return Objects.hash(room, organizer, start, end, title, attendees, needsProjector);
}
@Override
public String toString() {
return "Booking[" + room + ", " + title + ", " + start + " -> " + end
+ ", organizer=" + organizer + ", attendees=" + attendees
+ ", projector=" + needsProjector + "]";
}
public static final class Builder {
private String room;
private String organizer;
private LocalDateTime start;
private LocalDateTime end;
private String title = "Untitled meeting"; // default for an optional field
private List<String> attendees = new ArrayList<>();
private boolean needsProjector;
private Builder() {}
public Builder room(String room) { this.room = room; return this; }
public Builder organizer(String organizer) { this.organizer = organizer; return this; }
public Builder from(LocalDateTime start) { this.start = start; return this; }
public Builder until(LocalDateTime end) { this.end = end; return this; }
public Builder title(String title) { this.title = title; return this; }
public Builder attendee(String name) { this.attendees.add(name); return this; }
public Builder needsProjector(boolean yes) { this.needsProjector = yes; return this; }
public Booking build() {
// All validation happens here, once, when every field is known.
if (room == null || room.isBlank()) {
throw new IllegalStateException("room is required");
}
if (organizer == null || organizer.isBlank()) {
throw new IllegalStateException("organizer is required");
}
if (start == null || end == null) {
throw new IllegalStateException("start and end are required");
}
if (!end.isAfter(start)) {
throw new IllegalStateException("end must be after start, got " + start + " -> " + end);
}
return new Booking(this);
}
}
}
Four things to notice, because they're where most builder tutorials go wrong.
The Booking constructor is private and takes the builder as its only argument. Nobody can write new Booking(...) outside the class, so nobody can skip validation. The fields are final, which is what makes the object immutable.
The constructor calls List.copyOf(b.attendees). That line looks optional and isn't. Without it, the booking and the builder share one list, and I'll show you what that breaks.
build() is the only place that validates. Setters like room(...) accept anything, because a builder is half-finished by nature. At the moment of build() all the fields are known, so that's when "end must be after start" can be checked. A rule that compares two fields can't live in a setter.
title has a default of "Untitled meeting". Optional fields get their defaults in the builder, not in Booking. That keeps Booking free of null-handling.
Checkpoint: compile it.
javac -d out src/main/java/com/example/booking/Booking.java
You should see no output. If you see pattern matching in instanceof is not supported, jump to the troubleshooting section.
Step 2: Add a step builder for the required fields
Goal: make "forgot the organizer" a compile error instead of a runtime exception.
src/main/java/com/example/booking/StepBooking.java
package com.example.booking;
import java.time.LocalDateTime;
// Step builder: the compiler forces room -> organizer -> from -> until, in that order.
public final class StepBooking {
public interface RoomStep { OrganizerStep room(String room); }
public interface OrganizerStep { StartStep organizer(String organizer); }
public interface StartStep { EndStep from(LocalDateTime start); }
public interface EndStep { OptionalStep until(LocalDateTime end); }
public interface OptionalStep {
OptionalStep title(String title);
OptionalStep attendee(String name);
Booking build();
}
private StepBooking() {}
public static RoomStep builder() {
return new Impl();
}
private static final class Impl implements RoomStep, OrganizerStep, StartStep, EndStep, OptionalStep {
private final Booking.Builder delegate = Booking.builder();
public OrganizerStep room(String room) { delegate.room(room); return this; }
public StartStep organizer(String organizer) { delegate.organizer(organizer); return this; }
public EndStep from(LocalDateTime start) { delegate.from(start); return this; }
public OptionalStep until(LocalDateTime end) { delegate.until(end); return this; }
public OptionalStep title(String title) { delegate.title(title); return this; }
public OptionalStep attendee(String name) { delegate.attendee(name); return this; }
public Booking build() { return delegate.build(); }
}
}
Each required field gets its own tiny interface that returns the next one. RoomStep only has room(...), and it returns an OrganizerStep. So after .room("Fuji") the compiler only lets you call .organizer(...). Only once you reach OptionalStep do build(), title(...) and attendee(...) appear.
The private Impl class implements all five interfaces and hands the real work to the regular builder, so the validation rules live in exactly one place. The reader never sees Impl.
I'd use the regular builder by default and reach for this one only when a missed required field is a real risk, such as in a public API. It's a lot of boilerplate for four fields. IDE autocomplete makes it pleasant to use, though, because the next method is always the only suggestion.
Checkpoint:
javac -d out $(find src/main -name '*.java')
Again, no output means success. On Windows PowerShell, list the three files explicitly instead of using find.
Step 3: Write the App that uses both
Goal: see all the pieces work together, including toBuilder().
src/main/java/com/example/booking/App.java
package com.example.booking;
import java.time.LocalDateTime;
public class App {
public static void main(String[] args) {
Booking standup = Booking.builder()
.room("Fuji")
.organizer("priya")
.from(LocalDateTime.of(2026, 11, 2, 9, 30))
.until(LocalDateTime.of(2026, 11, 2, 9, 45))
.title("Daily standup")
.attendee("sam")
.attendee("lee")
.build();
System.out.println(standup);
System.out.println("length: " + standup.length());
// Same booking, one field changed, original untouched.
Booking moved = standup.toBuilder()
.room("Everest")
.needsProjector(true)
.build();
System.out.println(moved);
System.out.println("original still in " + standup.room());
// Step builder: required fields in order, then optional ones.
Booking review = StepBooking.builder()
.room("K2")
.organizer("sam")
.from(LocalDateTime.of(2026, 11, 3, 14, 0))
.until(LocalDateTime.of(2026, 11, 3, 15, 0))
.title("Design review")
.build();
System.out.println(review);
try {
Booking.builder().room("Fuji").build();
} catch (IllegalStateException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}
toBuilder() is the answer to "I need the same booking in a different room". Records get this from with-ers, and a class with a builder gets it by copying its fields into a new builder. The original isn't touched.
Try it end to end
javac -d out $(find src/main -name '*.java')
java -cp out com.example.booking.App
Booking[Fuji, Daily standup, 2026-11-02T09:30 -> 2026-11-02T09:45, organizer=priya, attendees=[sam, lee], projector=false]
length: PT15M
Booking[Everest, Daily standup, 2026-11-02T09:30 -> 2026-11-02T09:45, organizer=priya, attendees=[sam, lee], projector=true]
original still in Fuji
Booking[K2, Design review, 2026-11-03T14:00 -> 2026-11-03T15:00, organizer=sam, attendees=[], projector=false]
rejected: organizer is required
Line 4 is the one to check. The moved booking is in Everest with a projector, and the original is still in Fuji. If you see all six lines, the hard part's done.
With Gradle, gradle run prints the same six lines.
Lock it in with checks
src/test/java/com/example/booking/BookingChecks.java
package com.example.booking;
import java.time.LocalDateTime;
public class BookingChecks {
static int run = 0;
static final LocalDateTime NINE = LocalDateTime.of(2026, 11, 2, 9, 0);
static void check(String name, boolean ok) {
run++;
if (!ok) throw new AssertionError("FAILED: " + name);
System.out.println("ok " + name);
}
static void rejects(String name, Runnable r) {
try {
r.run();
} catch (IllegalStateException e) {
check(name + " -> " + e.getMessage(), true);
return;
}
check(name, false);
}
static Booking.Builder valid() {
return Booking.builder().room("Fuji").organizer("priya")
.from(NINE).until(NINE.plusHours(1));
}
public static void main(String[] args) {
check("default title applied", valid().build().title().equals("Untitled meeting"));
rejects("missing room", () -> Booking.builder().organizer("p").from(NINE).until(NINE.plusHours(1)).build());
rejects("blank organizer", () -> valid().organizer(" ").build());
rejects("missing times", () -> Booking.builder().room("A").organizer("p").build());
rejects("end before start", () -> valid().until(NINE.minusMinutes(5)).build());
Booking.Builder reused = valid().attendee("sam");
Booking first = reused.build();
reused.attendee("lee");
Booking second = reused.build();
check("reused builder: first booking unchanged", first.attendees().size() == 1);
check("reused builder: second booking sees both", second.attendees().size() == 2);
try {
first.attendees().add("x");
check("attendees immutable", false);
} catch (UnsupportedOperationException e) {
check("attendees immutable", true);
}
Booking copy = first.toBuilder().room("K2").build();
check("toBuilder changes only what you ask", copy.room().equals("K2") && first.room().equals("Fuji"));
check("toBuilder keeps attendees", copy.attendees().equals(first.attendees()));
check("equal bookings are equal", valid().build().equals(valid().build()));
check("equal bookings share hashCode", valid().build().hashCode() == valid().build().hashCode());
Booking viaSteps = StepBooking.builder().room("Fuji").organizer("priya")
.from(NINE).until(NINE.plusHours(1)).build();
check("step builder builds the same booking", viaSteps.equals(valid().build()));
System.out.println(run + " checks passed");
}
}
This is a hand-rolled harness, not JUnit. It's ugly on purpose. Every rule you built has a check, and the reused builder pair is the one that guards the defensive copy.
javac -d out $(find src -name '*.java')
java -ea -cp out com.example.booking.BookingChecks
ok default title applied
ok missing room -> room is required
ok blank organizer -> organizer is required
ok missing times -> start and end are required
ok end before start -> end must be after start, got 2026-11-02T09:00 -> 2026-11-02T08:55
ok reused builder: first booking unchanged
ok reused builder: second booking sees both
ok attendees immutable
ok toBuilder changes only what you ask
ok toBuilder keeps attendees
ok equal bookings are equal
ok equal bookings share hashCode
ok step builder builds the same booking
13 checks passed
Why the constructor is private
A builder earns its keep in three situations. A class has many fields and most are optional. Several fields have the same type, so new Booking("Fuji", "priya", ...) can be silently swapped. Or the object has rules that span fields, like start and end. If you have two required fields and nothing else, a plain constructor is shorter, clearer and fine.
The private constructor is what makes the pattern hold. The builder is a nested static class, so it can see the private constructor and fields, and nothing outside can. There's exactly one way to make a Booking, it goes through build(), and build() validates. Every Booking that exists has passed those checks, which is the guarantee you can lean on elsewhere in the code.
The method names are a style choice. I used from and until instead of start and end because .from(...).until(...) reads like the sentence you'd say out loud. Some teams prefix everything with with, as in withRoom. Pick one and keep it.
If your class is only data, with no rules, consider a record instead: how to use Java records covers validation and with-ers, and a record with a compact constructor is often all you need. For the broader picture of why Booking is safe to share between threads, see how to create an immutable class in Java. And if you want the generated version of all this, Lombok's @Builder writes the fluent API for you; setup and usage are in how to use Lombok to reduce boilerplate.
If something breaks
I forced each of these so the messages are exact.
The builder is shared with the booking
I removed List.copyOf and changed that line to this.attendees = b.attendees;, then ran the checks:
Exception in thread "main" java.lang.AssertionError: FAILED: reused builder: first booking unchanged
at com.example.booking.BookingChecks.check(BookingChecks.java:12)
at com.example.booking.BookingChecks.main(BookingChecks.java:42)
Cause: the booking and the builder pointed at the same ArrayList. When I reused the builder to make a second booking and added lee, the first booking changed too. Fix: copy in the constructor, as in Step 1. Use List.copyOf and not new ArrayList<>(...), so the booking's list is also immutable.
Booking(Builder) has private access in Booking
com/example/booking/E1.java:2: error: Booking(Builder) has private access in Booking
public class E1 { public static void main(String[] a) { Booking b = new Booking(Booking.builder()); } }
^
This is the design working. Use Booking.builder().
cannot find symbol ... location: interface OrganizerStep
com/example/booking/E2.java:2: error: cannot find symbol
public class E2 { public static void main(String[] a) { StepBooking.builder().room("Fuji").build(); } }
^
symbol: method build()
location: interface OrganizerStep
You called build() before supplying every required field. That's the step builder doing its job. Call organizer, from and until first. Calling a step out of order gives the same shape of error, for example organizer(String) with location: interface RoomStep.
pattern matching in instanceof is not supported in -source 11
src/main/java/com/example/booking/Booking.java:58: error: pattern matching in instanceof is not supported in -source 11
return o instanceof Booking other
^
(use -source 16 or higher to enable pattern matching in instanceof)
Your compiler language level is below 16. Check javac -version as well as java -version; they can point at different JDKs, and IDEs sometimes keep an older project language level than the installed JDK. If you must stay on 11, write the classic if (!(o instanceof Booking)) return false; Booking other = (Booking) o;.
IllegalStateException: room is required at runtime
That's your own validation firing, with the right type. I used IllegalStateException because the builder is in the wrong state to build. Some teams prefer IllegalArgumentException. Whichever you choose, keep it consistent, and keep the message specific. "organizer is required" tells you what to fix. "invalid booking" doesn't.
Before you ship it
- Thread safety.
Bookingis safe to share between threads because it's immutable. ABooking.Builderis not, so don't share one builder across threads. Build in the thread that fills it. - Don't validate in the setters. I've seen builders that throw from
room(null). It looks tidy, and it breaks rules that depend on two fields. Keep checks inbuild(). - Equality. I wrote
equalsandhashCodeby hand to keep this dependency-free. Check the contract in how to implement equals() and hashCode() properly if you add fields. - Subclasses. A builder for a class that has subclasses needs a self-referencing generic (
Builder<T extends Builder<T>>). I'd avoid that if I could, and prefer composition. If you've reached that point, a different pattern may fit better. - Moving to JUnit 5. Once you have a build tool with dependency access, move the checks into JUnit tests with
assertThrows, and see how to write unit tests with JUnit 5. I didn't run JUnit for this article, so pin the current version from Maven Central yourself. - Related patterns. If you want to see the fluent style without the construction part, read how to write a fluent API in Java. For wrapping objects instead of building them, see how to implement the decorator pattern in Java.
- Version note. Everything here works on Java 16 and later. On Java 21 you can swap the
Bookingclass for a record plus a small builder if you want, though the private-constructor guarantee is then only as strong as the record's compact constructor.
What you did
- Made
Bookingimmutable with final fields and a private constructor. - Put every default in the builder and every cross-field rule in
build(). - Copied the attendee list in the constructor so a reused builder can't change an existing booking.
- Added
toBuilder()for "same thing, one change". - Built a step builder so a missing required field fails at compile time.
- Wrote 13 checks and watched each failure mode produce the message you'd expect.
Builder pattern questions people ask
What is the builder pattern in Java used for?
It separates building an object from the object itself. You use it when a class has many optional fields, several parameters of the same type, or rules that span fields. The finished object can stay immutable, and the call site reads as named steps.
Is the builder pattern the same as a fluent API?
No. A fluent API is a style where each call returns something you can chain. A builder uses that style, but its purpose is to construct and validate an object. You can have a fluent API with no builder, such as stream().filter().map().
Should I use Lombok @Builder or write the builder by hand?
For plain data classes with no cross-field rules, Lombok saves a lot of typing. Write it by hand when you need validation in build(), a step builder, or when you don't want an annotation processor in the build. Lombok's @Builder also lets you add your own build() logic by hand-writing parts of the builder class.
Can a builder be reused to create several objects?
Yes, as long as build() copies any mutable state, as Booking does with List.copyOf. Without the copy, objects built from the same builder can affect each other.