Skip to content

VOL 06 / CH 08 / LESSON 01

8.1 From Direct Construction to Factory Families ​

An import job starts with text files and later needs other formats. A tournament service uses memory storage locally and database adapters in production. If every caller selects implementations, adding a configuration becomes a project-wide search for new.

Creation logic deserves a boundary when callers must otherwise know choices, assembly sequences, or compatibility rules owned elsewhere. Creational patterns let callers use an object while the assembly code decides how to obtain it.

Start with the simplest construction approach ​

Before choosing a creation method, consider the following scenarios in order:

ScenarioPreferred Approach
Few parameters, clear semanticsPublic constructor
Multiple clearly named creation semantics for the same typeStatic named factory
Caller knows only the abstraction; assembly code chooses the implementationSimple factory / registry / dependency injection
Parent class needs to allow subclasses to replace a specific creation stepFactory Method
Must switch a family of compatible products togetherAbstract Factory
Object has many optional settings or requires step-by-step validationBuilder
Deriving a new object from existing configuration is more natural than rebuilding from scratchPrototype / explicit copy

If new Money(amount, currency) is already clear, avoid adding MoneyFactoryFactory just for pattern completeness.

Static Named Factory: Clarify Creation Semantics ​

Complete types in this section use Java 21. Put each public type in its own matching .java file. Fragments that depend on project types are identified separately.

java
public record MatchId(java.util.UUID value) {
    public MatchId {
        java.util.Objects.requireNonNull(value, "value");
    }

    public static MatchId newId() {
        return new MatchId(java.util.UUID.randomUUID());
    }

    public static MatchId parse(String text) {
        java.util.Objects.requireNonNull(text, "text");
        java.util.UUID value = java.util.UUID.fromString(text);
        if (!value.toString().equalsIgnoreCase(text)) {
            throw new IllegalArgumentException("expected canonical UUID");
        }
        return new MatchId(value);
    }
}

newId() and parse() have names that reflect distinct origins and can hide normalization, caching, or specific return type details. They are not GoF Factory Methods: here, the type itself provides static creation entry points, without relying on subclasses to override a creation step.

parse() accepts the full 8-4-4-4-12 UUID form with either letter case. Null throws NullPointerException; invalid syntax throws IllegalArgumentException. The round-trip comparison rejects shortened groups regardless of parser leniency.

Simple Factory: Keep Runtime Selection at the Assembly Edge ​

This project fragment assumes a RankingStrategy interface with Elo and Points implementations. Unknown names are rejected; a null name throws NullPointerException.

java
public final class RankingStrategyFactory {
    public RankingStrategy create(String configuredType) {
        java.util.Objects.requireNonNull(configuredType, "configuredType");
        return switch (configuredType) {
            case "elo" -> new EloRankingStrategy();
            case "points" -> new PointsRankingStrategy();
            default -> throw new IllegalArgumentException(
                    "unsupported ranking strategy: " + configuredType);
        };
    }
}

This switch is not inherently a bad design. Systems must have some place where external configuration maps to concrete implementations; concentrating that mapping at the assembly edge is easier to review than scattering conditional branches throughout business code.

A registry can map names to creation functions. This example is fixed and unmodifiable; it does not implement dynamic plugin discovery:

java
java.util.Map<String, java.util.function.Supplier<RankingStrategy>> providers =
        java.util.Map.of(
        "elo", EloRankingStrategy::new,
        "points", PointsRankingStrategy::new);

Handle an unknown key before calling Supplier.get(), so a configuration typo does not become an obscure null-pointer exception. These constructor references create new strategies, but Supplier itself makes no such promise. Independent modules could register entries at startup, rejecting duplicates before freezing the map. Reuse, resource ownership, and startup failure remain part of the assembly contract. The Java Supplier documentation explicitly permits reused results.

Factory Method: Subclasses override creation hooks in the process flow ​

The typical structure of Factory Method is: the parent class has a stable workflow, where one step's created product is decided by the child class.

java
public interface RecordReader extends AutoCloseable {
    java.util.List<String> readAll() throws java.io.IOException;
    @Override
    void close() throws java.io.IOException;
}

public record ImportReport(java.util.List<String> records) {
    public ImportReport {
        records = java.util.List.copyOf(records);
    }
}

public abstract class ImportJob {
    public final ImportReport run(java.nio.file.Path source)
            throws java.io.IOException {
        RecordReader reader = java.util.Objects.requireNonNull(
                createReader(source), "reader");
        try (reader) {
            return new ImportReport(reader.readAll());
        }
    }

    protected abstract RecordReader createReader(java.nio.file.Path source)
            throws java.io.IOException;
}

public final class LineRecordReader implements RecordReader {
    private final java.io.BufferedReader reader;

    public LineRecordReader(java.nio.file.Path source)
            throws java.io.IOException {
        reader = java.nio.file.Files.newBufferedReader(
                source, java.nio.charset.StandardCharsets.UTF_8);
    }

    @Override
    public java.util.List<String> readAll() throws java.io.IOException {
        var lines = new java.util.ArrayList<String>();
        String line;
        while ((line = reader.readLine()) != null) {
            lines.add(line);
        }
        return lines;
    }

    @Override
    public void close() throws java.io.IOException { reader.close(); }
}

public final class LineImportJob extends ImportJob {
    @Override
    protected RecordReader createReader(java.nio.file.Path source)
            throws java.io.IOException {
        return new LineRecordReader(source);
    }
}

For a source containing the two lines alpha and 报名, new LineImportJob().run(path).records() returns [alpha, 报名]; an empty file produces an empty list. This reader handles UTF-8 physical text lines, not CSV quoting or other format rules. It also retains the whole file in memory.

The parent reads, creates an unmodifiable report snapshot, and closes the reader; subclasses select the reader. Closure still runs after a read failure. If both reading and closing fail, the close exception appears in the original exception’s getSuppressed(). A factory that fails before returning must release resources it already acquired, because the parent never receives them. Java’s resource-closing rules specify this behavior.

If the parent class has no stable flow other than calling createReader(), this hierarchy might just be making a simple factory unnecessarily complicated. In such cases, injecting a RecordReaderFactory through the constructor is more direct.

Abstract Factory: Overall Switching of a Family of Products ​

Suppose a tournament application has local-demo and production configurations. This assembly fragment assumes project-defined repository and event ports and their in-memory implementations:

java
public interface TournamentInfrastructure {
    TournamentRepository tournaments();
    EventPublisher events();
}

public final class InMemoryInfrastructure
        implements TournamentInfrastructure {
    private final InMemoryTournamentRepository repository =
            new InMemoryTournamentRepository();
    private final InMemoryEventPublisher events =
            new InMemoryEventPublisher();

    @Override
    public TournamentRepository tournaments() { return repository; }

    @Override
    public EventPublisher events() { return events; }
}

A PostgresKafkaInfrastructure could supply production adapters. Assembly selects one configuration and obtains the related objects. This factory reuses its two instances; it does not create a new pair for every call. Returning PostgreSQL and Kafka adapters together does not provide atomic cross-system commits. Reliable event publication still needs transaction coordination or an Outbox.

The Abstract Factory pattern is best suited for scenarios where the product family dimension is stable, but entire families switch together. It comes with a clear trade-off: adding a new product to an existing family (such as introducing AuditStore) typically requires modifying the factory interface and all family implementations. As a result, it does not provide full extensibility across all dimensions.

"Compatible Products" Still Require Type and Test Assurance ​

Simply returning two objects from the same factory does not automatically make them compatible. Contracts must specify:

  • Whether transactions, connections, or lifecycles are shared;
  • Whether the closure of one object affects the other;
  • Whether events and stored data versions remain aligned;
  • Whether cross-factory family usage is permitted;
  • How already-created resources should be released if object creation fails.

For resource-owning products, the factory must clearly define which entity is responsible for closure. Creating an object marks only the beginning of its lifecycle, not its end.

Factory, DI Containers, and Service Locator ​

Dependency injection containers can select and assemble specific implementations at the system entry point, taking on part of the factory's responsibilities. However, business objects should still declare their dependencies via constructors, not through method calls to a global container:

The following hypothetical container API illustrates the hidden lookup:

java
// Hide dependencies, not recommended
var repository = Container.resolve(TournamentRepository.class);

This Service Locator pattern removes dependencies from method signatures, making testing and impact analysis more difficult. The container belongs to the composition root and should not propagate into business APIs.

This Section's Design Questions ​

  1. Should the caller truly be unaware of the specific implementation?
  2. Where does change occur, within a single product, during the parent class creation step, or across the entire product family?
  3. Who makes the implementation choice, and when does that choice happen, during compilation, at startup, or at request time?
  4. Does an object hold external resources, and who is responsible for closing them?
  5. Are new implementations coming from independent modules, making a registry worthwhile?
  6. Is a simple constructor or a static named factory already sufficient?

The next section addresses object internal composition, copy semantics, and shared instance scope.

References ​

Built with VitePress | Software Systems Atlas