Skip to content

VOL 06 / CH 10 / LESSON 01

10.1 Replaceable Strategies, Flow Skeletons, Responsibility Chains, and Commands ​

A match may use different rating algorithms. A batch import must validate before writing, registration checks must stop after rejection, and closing a tournament may wait in a queue. Each problem concerns how work moves to its next step.

Behavioral patterns focus on "who decides the next step, how requests flow, and where variation belongs." This section explores four common collaboration patterns: replaceable algorithms, fixed flow skeletons, step-by-step request processing, and transforming a request into a storable or schedulable value.

Strategy: Replace a Complete Decision ​

These Java 21 project fragments assume domain inputs, reports, repositories, and related types. Put each public type in its own file. Names reused from earlier examples do not imply identical field sets; these snippets do not connect to a database or queue.

When multiple algorithms satisfy the same input-output contract and the caller needs to select one based on configuration or context, use Strategy.

java
@FunctionalInterface
public interface RankingPolicy {
    RatingChange calculate(RatingSnapshot before, MatchResult result);
}

public final class RankingService {
    private final RankingPolicy policy;

    public RankingService(RankingPolicy policy) {
        this.policy = java.util.Objects.requireNonNull(policy, "policy");
    }

    public RatingChange rank(RatingSnapshot before, MatchResult result) {
        return policy.calculate(before, result);
    }
}

Implementations can be classes, lambdas, or method references:

java
RankingPolicy fixedPoints = (before, result) ->
        RatingChange.fixed(result.winnerId(), 10);

The strategy contract must specify determinism, rounding, boundary conditions, and failure semantics. Even if two implementations have the same signature, switching between them (such as one that accesses the current time and another that is a pure function) can invalidate caching and replay mechanisms.

Don't break every if into a Strategy. When branches are few, the set of options is closed, and rules change together, an exhaustive switch is more intuitive. Use Strategy only when the algorithm family evolves independently or when runtime switching is required.

Template Method: Fixed Flow, Only a Few Steps Opened ​

The Template Method pattern defines a stable algorithm skeleton in a base class, allowing subclasses to override only specific, controlled steps:

java
public abstract class BatchImport {
    public final ImportReport execute(Source source) {
        ParsedBatch parsed = parse(source);
        ValidationResult validation = validate(parsed);
        if (!validation.accepted()) {
            return ImportReport.rejected(validation.errors());
        }
        return persist(parsed);
    }

    protected abstract ParsedBatch parse(Source source);

    protected abstract ValidationResult validate(ParsedBatch batch);

    protected abstract ImportReport persist(ParsedBatch batch);
}

The final execute() fixes the parse → validate → persist call order and skips persistence on rejection. Subclasses must implement validation; there is no accept-everything default. final does not prove the validator correct or prevent a misbehaving hook from writing data. Require parsing and validation to have no persistent side effects, and test that rejection performs zero writes. Exceptions propagate here; persist owns atomic writing.

The cost of the Template Method is inheritance coupling: subclasses become dependent on the parent class's method invocation sequence. As the number of hooks increases, an implicit agreement emerges; “which methods must be overridden to make the system work.” If steps require flexible composition, consider using multiple Strategy patterns instead: Parser + Validator + Persister is typically clearer and more maintainable.

Strategy and Template Method ​

text
Strategy: Replace the entire algorithm or step by composition, allowing the caller or assembly layer to choose
Template Method: Fix the overall flow through inheritance, leaving only a few hooks for subclasses to implement

The two can work together: the template establishes a fixed workflow for transactions and auditing, while delegating a specific step to an injected strategy. Don’t reject simple composition out of concern for pattern purity.

Chain of Responsibility: Let Requests Pass Through Ordered Handlers ​

The responsibility chain pattern is suitable for a sequence of handlers that evaluate, enhance, or terminate a request in order. First, define the propagation of results so that no individual handler secretly decides whether to pass the request to the next one:

java
public sealed interface CheckResult {
    record Continue(RegistrationContext context) implements CheckResult {}
    record Reject(String code, String reason) implements CheckResult {}
}

@FunctionalInterface
public interface RegistrationCheck {
    CheckResult check(RegistrationContext context);
}

public final class RegistrationChecks {
    private final java.util.List<RegistrationCheck> checks;

    public RegistrationChecks(java.util.List<RegistrationCheck> checks) {
        this.checks = java.util.List.copyOf(checks);
    }

    public CheckResult run(RegistrationContext initial) {
        RegistrationContext current = java.util.Objects.requireNonNull(initial);
        for (RegistrationCheck check : checks) {
            CheckResult result = java.util.Objects.requireNonNull(
                    check.check(current), "check result");
            if (result instanceof CheckResult.Reject rejected) {
                return rejected;
            }
            current = java.util.Objects.requireNonNull(
                    ((CheckResult.Continue) result).context(), "context");
        }
        return new CheckResult.Continue(current);
    }
}

This eligibility chain specifies identity, ban, level, then capacity checks. Continue passes a possibly updated context onward; Reject returns immediately. An empty list allows the request, and duplicate handlers run twice. List.copyOf fixes order and rejects null elements; it does not prove all required checks were assembled.

If the second handler rejects, the trace must be [identity, ban], with neither level nor capacity checks executed. Null input, handler results, or continuation contexts fail immediately; handler exceptions propagate. A capacity check is only an observation: allocating the slot still needs an atomic conditional update.

The responsibility chain has multiple semantic interpretations, never mix them:

  • The first handler that can process the request terminates the chain;
  • All handlers sequentially enhance the request;
  • If any handler rejects the request, the chain short-circuits;
  • All errors are collected and returned uniformly.

Exception propagation, asynchronous steps, and compensation mechanisms must also be consistently defined. Placing a database write operation into a chain that may fail at any point introduces partial side effects. Validation chains should prioritize purity or clearly defined transaction boundaries to maintain consistency.

Command: Turning an Intent into a Value ​

A Command encapsulates the receiver, parameters, and execution entry point, enabling requests to be queued, logged, retried, or composed:

java
public record CloseTournament(
        CommandId commandId,
        TournamentId tournamentId,
        UserId requestedBy,
        java.time.Instant requestedAt) {}

public final class CloseTournamentHandler {
    private final TournamentRepository tournaments;

    public CloseTournamentHandler(TournamentRepository tournaments) {
        this.tournaments = java.util.Objects.requireNonNull(tournaments);
    }

    public CommandResult handle(CloseTournament command) {
        Tournament tournament = tournaments.getRequired(command.tournamentId());
        tournament.close(command.requestedAt(), command.requestedBy());
        tournaments.save(tournament);
        return CommandResult.accepted(command.commandId());
    }
}

Commands in application architecture are usually immutable data, with handlers executing use cases. They need not belong to a hierarchy with an execute() method. This example shows one invocation without commandId deduplication. Derive or verify requestedBy against the authenticated context; a client-supplied name is not an authorization credential. If the deadline depends on actual processing time, do not simply trust requestedAt.

Retryable Commands Require Idempotency Design ​

With at-least-once delivery, handle these possible cases:

  • The same command is executed twice;
  • The operation succeeds but the acknowledgment is lost;
  • An older consumer reads a new command;
  • The command arrives after it has expired.

Reliable queued commands need stable commandId values, deduplication or state tracking, schema versioning, timeouts, and dead-letter policies. If deduplication and business writes use separate transactions, a crash can still occur between them. External effects need idempotency keys or coordination such as an Outbox. Simply serializing an object into a queue does not constitute a robust Command system.

Undo Is Not a Universal Capability ​

Text editing can maintain reverse operations; however, payments, emails, and external APIs often cannot be truly undone, only compensatory actions are possible. Undo requires preserving sufficient prior state and handling failures during the reverse operation. Avoid adding undo() universally to interfaces, only to have most commands throw "not supported" exceptions.

Four Pattern Selection Table ​

Change/Cooperation IssuePattern
Replace a complete algorithm under the same contractStrategy
Fixed skeleton with only a few inheritance hooks exposedTemplate Method
Requests processed in a sequence of handlers or short-circuitedChain of Responsibility
Requests requiring logging, queuing, replay, or schedulingCommand

References ​

Built with VitePress | Software Systems Atlas