We prefer immutable typesafe types over flexibility and "short" class definitions. This makes the code more robust and less error-prone. References to other entities might need to be mutable; if so, try to init them once and throw an exception if set again. Example:
Builder initStop(Stop stop) {
this.stop = requireNotInitialized(this.stop, stop);
}
You may use records, but avoid using records if you cannot encapsulate them properly. Generally,
records are considered appropriate and useful for throwaway compound types private to an
implementation, such as hash table keys or compound values in a temporary list or set. On the other
hand, records are generally not appropriate in the domain model where we insist on full
encapsulation, which records cannot readily provide. Be especially aware of array fields (which
cannot be protected) and collections (remember to make a defensive copy). Consider overriding
toString
. But if you need to override equals
and hashCode
, then it is probably not worth using
a record. The implicit equals
and hashCode
implementations for records behave as if they call
equals
and hashCode
on each field of the record, so their behavior will depend heavily on the
types of these fields.
OTP used a simple builder pattern in many places, especially when creating immutable types.
- Use factory methods to create builder—either
of
orcopyOf
. ThecopyOf
uses an existing instance as its base. Theof
creates a builder with all default values set. All constructors should be private (or package-local) to enforce use of the factory methods. - If the class has more than 5 fields, then avoid using an inner class builder. Instead, create a builder in the same package.
- Make all fields in the main class final to enforce immutability.
- Consider using utility methods for parameter checking, like
Objects#requireNonNull
andObjectUtils#ifNotNull
. - Validate all fields in the main type constructor (i.e., not in the builder), especially null checks. Prefer default values over null checks. All business logic using the type can rely on its validity.
- You may keep the original instance in the builder to avoid creating a new object if nothing changed. This prevents polluting the heap for long-lived objects and makes comparison very fast.
- There is no need to provide all get accessors in the builder if not needed.
- Unit-test builders and verify all fields are copied over.
- For nested builders, see the field
nested
in the example.
Builder Example
/**
* THIS CLASS IS IMMUTABLE AND THREAD-SAFE
*/
public class A {
public static final A DEFAULT = new A();
private final List<String> names;
private final int age;
private final B nested;
private A() {
this.names = List.of("default");
this.age = 7;
this.nested = B.of();
}
private A(Builder builder) {
this.names = List.copyOf(builder.names);
this.age = builder.age;
this.nested = builder.nested();
if(age < 0 || age > 150) {
throw new IllegalArgumentException("Age is out of range[0..150]: " + age);
}
}
public static A.Builder of() { return DEFAULT.copyOf(); }
public A.Builder copyOf() { return new Builder(this); }
public List<String> listNames() { return names; }
public int age() { return age; }
public boolean equals(Object other) { ... }
public int hashCode() { ... }
public String toString() { return ToStringBuilder.of(A.class)...; }
public static class Builder {
private final A original;
private final List<String> names;
private int age;
private B.Builder nested = null;
public Builder(A original) {
this.original = original;
this.names = new ArrayList<>(original.names);
this.age = original.age;
}
public Builder withName(String name) { this.names.add(name); return this; }
public int age() { return age; }
public Builder withAge(int age) { this.age = age; return this; }
private B nested() { return nested==null ? original.nested() : nested.build(); }
public Builder withB(Consumer<B.Builder> body) {
if(nested == null) { nested = original.nested.copyOf(); }
body.accept(nested);
return this;
}
public A build() {
A value = new A(this);
return original.equals(value) ? original : value;
}
}
}