In part 1, we replaced a long list of Cat constructors with a builder. The call site became easier to read:
Cat cat = new Cat.Builder(2)
.weightGrams(3000)
.lengthCm(25)
.color(Cat.Color.BLUE)
.build();
But readable code is only half of the story. What happens if we forget the weight? What if someone passes a negative length? And does making every field final really make the object immutable? Let's finish the builder from part 1.
Required and optional values
For this example, a cat must have an age, a weight, and a length. Its color and name can be unknown. We can ask for the age when creating the builder, use named methods for the other numeric values, and check that they were supplied when build() is called.
The builder uses Integer for weight and length so that null means “not supplied yet.” The finished Cat uses int fields; an incomplete builder never becomes a Cat.
public final class Cat {
public enum Color { UNKNOWN, WHITE, BLACK, RED, BLUE, CHOCOLATE }
private final int age;
private final int weightGrams;
private final int lengthCm;
private final Color color;
private final String name;
private Cat(Builder builder) {
if (builder.weightGrams == null || builder.lengthCm == null) {
throw new IllegalStateException("Weight and length are required");
}
if (builder.age < 0 || builder.weightGrams <= 0 || builder.lengthCm <= 0) {
throw new IllegalArgumentException("Invalid age, weight, or length");
}
if (builder.color == null || builder.name == null) {
throw new IllegalArgumentException("Color and name cannot be null");
}
this.age = builder.age;
this.weightGrams = builder.weightGrams;
this.lengthCm = builder.lengthCm;
this.color = builder.color;
this.name = builder.name;
}
public int getAge() { return age; }
public int getWeightGrams() { return weightGrams; }
public int getLengthCm() { return lengthCm; }
public Color getColor() { return color; }
public String getName() { return name; }
public static final class Builder {
private final int age;
private Integer weightGrams;
private Integer lengthCm;
private Color color = Color.UNKNOWN;
private String name = "";
public Builder(int age) {
this.age = age;
}
public Builder weightGrams(int value) {
this.weightGrams = value;
return this;
}
public Builder lengthCm(int value) {
this.lengthCm = value;
return this;
}
public Builder color(Color value) {
this.color = value;
return this;
}
public Builder name(String value) {
this.name = value;
return this;
}
public Cat build() {
return new Cat(this);
}
}
}
Now each number has a name at the call site. The optional values have explicit defaults: UNKNOWN for color and an empty string for name. These defaults are a choice for this example, not a rule of the pattern. In a real application, choose defaults that make sense for your data.
Validate before returning the object
The builder is allowed to be incomplete while we configure it. The Cat constructor checks the builder before assigning the final fields, so build() either returns a valid object or throws an exception:
Cat cat = new Cat.Builder(2)
.weightGrams(3000)
.lengthCm(25)
.name("Milo")
.build();
// Throws IllegalStateException: length was not supplied.
Cat incomplete = new Cat.Builder(2)
.weightGrams(3000)
.build();
// Throws IllegalArgumentException: weight must be positive.
Cat invalid = new Cat.Builder(2)
.weightGrams(-3)
.lengthCm(25)
.build();
Notice an important limit: the compiler still allows the incomplete call. The builder catches missing or invalid values at build time, not at compile time. If a value must be supplied, a constructor parameter is another option; use the API that makes mistakes easiest to spot for your class.
Validation belongs at the boundary where the finished object is created. If you later add another creation method, make sure it enforces the same rules. You can also reject bad inputs in builder methods to report errors earlier, but keep the finished object's rules consistent.
final is only part of immutability
Our Cat class is final, has no setters, and stores only primitives, a String, and an enum. After construction, callers cannot change its state through its public API. The builder itself is mutable, but the Cat copies its values; changing the builder later does not change an existing cat.
If a future version stores a mutable object, a final reference alone will not protect it. For example, wrapping a caller's list directly with Collections.unmodifiableList(toys) creates a read-only view of that same list. Changes through the original toys reference will still appear in the view. Copy first:
// If Cat later has a List<String> of toys:
this.toys = Collections.unmodifiableList(new ArrayList<>(builder.toys));
Here the copy separates the cat from the builder's list, and the wrapper prevents callers from changing the stored list through a getter. If the elements themselves are mutable, they may need their own copies too. See Oracle's guide to immutable objects and its explanation of unmodifiable collections for the distinction.
When should we use a builder?
A builder is useful when a class has several optional values, when arguments of the same type are easy to mix up, or when creation needs validation. It gives each value a name and keeps construction in one place. For a small class with two obvious required values, an ordinary constructor may be simpler.
The builder is a tool for making valid objects easy to create and mistakes easy to notice. It does not automatically make a class immutable or make invalid input impossible. Those properties come from the rules we write around build() and from how the finished object stores its data.
Saigon, Oct 8, 2026
Hau Nguyen / Jason.
Cover photo by Michiel Leunens on Unsplash.
