Skip to content

VOL 06 / CH 08 / LESSON 02

8.2 Builder, Prototype, and Instance Scope ​

Factory addresses "which implementation to choose," Builder handles "how to fully assemble a complex object," Prototype solves "how to derive new objects from existing ones," while Singleton isn't about assembly; it's about constraining the scope of instances.

Builder: Separate Optional Configuration from Final Invariants ​

Many constructor parameters do not automatically require a builder. If all parameters are required, a parameter object or record type might be more honest. Builders are better suited for:

  • Lots of options;
  • The creation process involves multiple steps to gather information;
  • Cross-field validation is required before construction;
  • An immutable result.

Save the complete type as TournamentPlan.java and use Java 21:

java
public final class TournamentPlan {
    private final String name;
    private final int capacity;
    private final java.time.Duration matchDuration;
    private final boolean ranked;
    private final Integer minimumLevel;

    private TournamentPlan(Builder b) {
        this.name = b.name;
        this.capacity = b.capacity;
        this.matchDuration = b.matchDuration;
        this.ranked = b.ranked;
        this.minimumLevel = b.minimumLevel;
    }

    public String name() { return name; }
    public int capacity() { return capacity; }
    public java.time.Duration matchDuration() { return matchDuration; }
    public boolean ranked() { return ranked; }
    public java.util.OptionalInt minimumLevel() {
        return minimumLevel == null ? java.util.OptionalInt.empty()
                : java.util.OptionalInt.of(minimumLevel);
    }

    public static Builder builder(String name, int capacity) {
        return new Builder(name, capacity);
    }

    public static final class Builder {
        private final String name;
        private final int capacity;
        private java.time.Duration matchDuration =
                java.time.Duration.ofMinutes(15);
        private boolean ranked;
        private Integer minimumLevel;

        private Builder(String name, int capacity) {
            if (name == null || name.isBlank()) {
                throw new IllegalArgumentException("name is blank");
            }
            if (capacity < 2) {
                throw new IllegalArgumentException("capacity < 2");
            }
            this.name = name;
            this.capacity = capacity;
        }

        public Builder matchDuration(java.time.Duration value) {
            this.matchDuration = java.util.Objects.requireNonNull(value);
            return this;
        }

        public Builder rankedFromLevel(int level) {
            this.ranked = true;
            this.minimumLevel = level;
            return this;
        }

        public TournamentPlan build() {
            if (matchDuration.isZero() || matchDuration.isNegative()) {
                throw new IllegalStateException("match duration must be positive");
            }
            if (ranked && minimumLevel < 1) {
                throw new IllegalStateException("minimum level < 1");
            }
            return new TournamentPlan(this);
        }
    }
}

Required parameters appear at builder() entry; optional settings have domain-specific names. The final object is created only after build() validates it. Place these statements in main to observe that reusing the builder leaves an earlier plan unchanged:

java
var builder = TournamentPlan.builder("Open", 16);
var first = builder.build();
var second = builder.matchDuration(java.time.Duration.ofMinutes(30))
        .rankedFromLevel(18).build();
System.out.println(first.matchDuration().toMinutes()); // 15
System.out.println(first.ranked());                    // false
System.out.println(second.minimumLevel().orElseThrow()); // 18

All field values here are immutable, so copying the fields is sufficient. A zero duration or level 0 fails at build(). The caller can repair the builder and try again; failure never returns a partially valid TournamentPlan.

Builders are typically mutable and not guaranteed to be thread-safe and should not be shared across threads. After construction, the final object should not retain references to the mutable collections of the builder; use snapshots like List.copyOf instead.

Builder Must Enforce Construction Rules ​

Suppose another API allows the following call without validation. It can still create invalid objects. This is not the interface of TournamentPlan.Builder above:

java
builder.name(null).capacity(-1).build();

If all fields can be set arbitrarily and build() doesn't validate, it merely converts setters into a chainable syntax. Its real value lies in:

  • Distinguish required from optional;
  • Use method names to express effective combinations;
  • Validate cross-field invariants in one place;
  • Hide intermediate states so unfinished products don't enter the business system.

Staged builders can be used when the construction order must be constrained by type, but they increase interface count and generic complexity. Most business objects are adequately validated at runtime.

Prototype: First define copy semantics ​

“Copy” could have at least three possible meanings:

  • Shallow copy: a new outer object with shared internal references;
  • Deep copy: recursively copies the entire reachable object graph;
  • Domain Copy: Copies only the values defined as templates, resets the ID, timestamp, and run status.

Business systems often need the third option. Deep copying also needs rules for cycles, shared subgraphs, and resource handles that cannot be copied; recursive constructor calls alone do not settle these issues.

This project fragment assumes immutable RewardRule values and domain-defined TournamentId and Tournament.draft(...) types and operations:

java
public record TournamentTemplate(
        String name,
        int capacity,
        java.time.Duration matchDuration,
        java.util.List<RewardRule> rewardRules) {

    public TournamentTemplate {
        if (name == null || name.isBlank() || capacity < 2) {
            throw new IllegalArgumentException("invalid name or capacity");
        }
        java.util.Objects.requireNonNull(matchDuration, "matchDuration");
        if (matchDuration.isZero() || matchDuration.isNegative()) {
            throw new IllegalArgumentException("match duration must be positive");
        }
        rewardRules = java.util.List.copyOf(rewardRules);
    }

    public TournamentTemplate withName(String newName) {
        return new TournamentTemplate(
                newName, capacity, matchDuration, rewardRules);
    }

    public Tournament instantiate(TournamentId newId,
                                  java.time.Instant createdAt) {
        java.util.Objects.requireNonNull(newId, "newId");
        java.util.Objects.requireNonNull(createdAt, "createdAt");
        return Tournament.draft(
                newId, name, capacity, matchDuration,
                rewardRules, createdAt);
    }
}

Immutable RewardRule values can be shared safely. List.copyOf fixes the list contents without deeply copying elements. withName() derives configuration; instantiate() creates a draft with the caller-supplied ID and time. It does not generate IDs or prevent reuse of an old one. Define both the copied values and responsibility for fresh identity, or two tournaments may leave the copier carrying the same identity card.

Java's Cloneable is merely a marker and does not declare a public clone() method; Object.clone() performs field-level shallow copying by default. For objects with mutable references, resource handles, or inheritance hierarchies, explicitly defined copy constructors, named copy methods, or immutable values are typically safer.

Serializing and deserializing is not a general deep copy solution: it binds copy semantics to a serialization format and introduces performance, versioning, type, and security issues.

Singleton: First Ask "Unique Within What Scope?" ​

"There is only one instance of the system" must specify the scope:

text
Per method call? Per request? Per thread?
Per DI container? Per class loader? Per JVM process?
The entire cluster? The entire tenant?

Static fields belong to a particular class identity: its name and defining class loader. Child loaders delegating to the same parent may share a class; loaders that independently define it have separate static fields. A static field alone also does not prevent calls to a public constructor. Separate processes do not share an instance this way. This provides neither cluster-wide uniqueness nor a substitute for distributed locks or database constraints.

Global access and a single instance are different concerns ​

java
import java.time.Clock;

public final class RegistrationService {
    private final Clock clock;

    public RegistrationService(Clock clock) {
        this.clock = java.util.Objects.requireNonNull(clock, "clock");
    }
}

The assembly layer can create just one Clock or service instance and inject it into all callers; business code doesn't need GlobalClock.getInstance(). This both controls instance count and maintains explicit, testable dependencies.

Common problems with global Singletons include:

  • Hide dependencies and initialization order;
  • Mutable state leaks between tests;
  • Concurrency rules are unclear;
  • Hard to decouple when dealing with multi-tenancy, multiple configurations, or several connection pools later on;
  • Difficult to manage lifecycle and shutdown order.

Log facades, enum constants, or stateless shared objects might suit a global entry point, but mutable configurations and resource lifecycles should be placed within well-defined boundaries.

Thread-safe initialization in Java doesn't make the object thread-safe ​

java
public enum ProcessIdentity {
    INSTANCE;

    private final java.util.UUID id = java.util.UUID.randomUUID();

    public java.util.UUID id() { return id; }
}

Save the enum as ProcessIdentity.java. Class initialization safely initializes the constant, but the name does not mean “one for the entire process” across defining class loaders. Enum serialization stores only the constant name; deserialization retrieves that constant from the resolved enum class. The id field is not written to the stream. Another process generates its own UUID when initializing the enum.

Mutable maps, counters, or connections inside a Singleton still need synchronization, visibility, and shutdown rules. Thread-safe construction does not make every method thread-safe. See the JLS class initialization rules and enum serialization specification.

Choosing Among Five Patterns ​

PatternPoint of ChangeTypical Risks
Factory MethodCreation steps for product in parent class flowIntroduces unnecessary inheritance for simple selection
Abstract FactoryConcrete implementation of a product familyAdding new product types to an existing product family is costly
BuilderAssembly and validation of complex objectsHalf-finished products leakage, superficial setter replacements
PrototypeDerive objects from existing valuesAmbiguous shallow/deep/domain copy semantics
SingletonNumber of instances within a given scopeGlobal state, hidden dependencies, misjudged scope

The name of the pattern isn't the goal. First, clearly define the change points, lifecycle, ownership, and invariants, then choose the smallest creation mechanism.

Practice ​

Choose a real object and respond separately:

  1. Which parameters are required and which are optional?
  2. Why is direct construction insufficient?
  3. If using Builder, which illegal combinations must be rejected by build()?
  4. If copying from a template, which fields are shared, copied, or reset?
  5. If only one instance is created, what scope does “one” refer to?

If these questions aren't clearly answered, don't select a pattern yet.

References ​

Built with VitePress | Software Systems Atlas