Skip to content

8.2 Builder, Prototype, and Instance Scope

Craftsmen are writing longer and longer constructors for complex objects, and copy semantics and global instances introduce new lifecycle issues.

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 Immutable State

A builder isn't necessary when a constructor has many parameters. If all parameters are required, a plain 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;
  • Hope the final object remains immutable.
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 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 parameters include a domain-specific name; the final object is created only after build() validation.

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 Goes Beyond another spelling of setter

The following Builder can still create illegal objects:

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;
  • Maintain a position-based invariant across fields;
  • 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 typically need a third option, not a blind deep copy.

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

    public TournamentTemplate {
        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) {
        return Tournament.draft(
                newId, name, capacity, matchDuration,
                rewardRules, createdAt);
    }
}

Immutable RewardRule can be safely shared without needing "deep copies" to create duplicate objects; entity IDs and creation times are regenerated at instantiation. The copy strategy is directly written into the type API, making it easier to review than a generic clone().

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?

Java static fields are constrained only to the class loader that loads the class; in a multi-process deployment, each process maintains its own instance. It cannot achieve cluster-wide uniqueness, nor can it replace distributed locks or database uniqueness constraints.

Global access and a single instance are different concerns

java
public final class RegistrationService {
    private final Clock clock;

    public RegistrationService(Clock clock) {
        this.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 questions about global Singleton patterns include:

  • Hide dependencies and initialization order;
  • Variable state leaks between tests;
  • Concurrent 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; }
}

Enums can provide reliable initialization and serialization semantics within the JVM/class-loader scope, but if a Singleton holds mutable maps, counters, or connections, separate design is still needed for synchronization, visibility, and shutdown. "Singleton creation is thread-safe" does not imply "all methods are thread-safe."

Selection Table for Five Modes

ModeKey ChangesTypical 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? Why is direct construction insufficient?
  2. If using Builder, which illegal combinations must be rejected by build()?
  3. If copying from a template, which fields are shared, copied, or reset?
  4. If only one instance is created, which "one" specifically belongs to the scope?

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

References

Built with VitePress | Software Systems Atlas