Skip to content

VOL 06 / CH 10 / LESSON 03

10.3 Traversals, Snapshots, External Operations, and Small Languages ​

This final group of behavioral patterns addresses four related but distinct concerns: collection traversal, undo history, syntax-tree operations, and rule interpretation. They can look interchangeable in a diagram, but they evolve for different reasons and lead to different implementations.

These four behavioral patterns are often hidden behind the scenes in languages and standard libraries: for-each over an Iterable uses its iterator (arrays use a different translation), editor history echoes the Memento pattern, compilers frequently use visitors to traverse syntax trees, and rule DSLs typically employ interpreters. Understanding the boundaries of these patterns is more important than manually coding boilerplate solutions.

Iterator: Expose the Traversal Protocol, Not the Storage Structure ​

These examples use Java 21. MatchSummary is an immutable project type; save each public top-level type separately.

java
public final class MatchHistory implements Iterable<MatchSummary> {
    private final java.util.List<MatchSummary> matches;

    public MatchHistory(java.util.List<MatchSummary> matches) {
        this.matches = java.util.List.copyOf(matches);
    }

    @Override
    public java.util.Iterator<MatchSummary> iterator() {
        return matches.iterator();
    }
}

This example follows the input list order, and each new iterator starts at the beginning. The list is a construction-time snapshot; remove() is unsupported and next() after exhaustion throws NoSuchElementException. Immutable elements are an additional assumption: List.copyOf does not deeply copy summaries.

Callers can use for-each without knowing storage details. Replacing this with paginated queries is not automatically transparent if snapshot, freshness, or resource-closing semantics change.

When designing an iterator, you must clearly define:

  • Whether the traversal order is stable;
  • Whether iteration can be repeated or is one-time and consumes the data;
  • Whether modifications to the collection are allowed during traversal;
  • Whether the iterator provides a snapshot or a live view;
  • How to report failures in lazy loading;
  • Who is responsible for closing database cursors, file handles, or other resources.

Java’s fail-fast iterators detect modification on a best-effort basis; they do not guarantee thread safety. For traversals across I/O operations, it's preferable to return a closeable Stream/cursor, and the caller should use try-with-resources to manage resource cleanup.

Memento: Save and Restore State While Preserving Encapsulation ​

Memento lets the editor create an opaque snapshot that a history manager stores and returns later. This complete type uses private snapshot fields and construction, plus an owner marker to reject snapshots from other editors:

java
public final class TournamentEditor {
    public record RoundDraft(String title) {
        public RoundDraft {
            if (title == null || title.isBlank()) {
                throw new IllegalArgumentException("blank round title");
            }
        }
    }
    private record Content(String name, java.util.List<RoundDraft> rounds) {
        private Content {
            if (name == null || name.isBlank()) {
                throw new IllegalArgumentException("blank name");
            }
            rounds = java.util.List.copyOf(rounds);
        }
    }
    public static final class Snapshot {
        private final Object owner;
        private final Content content;
        private Snapshot(Object owner, Content content) {
            this.owner = owner;
            this.content = content;
        }
    }

    private final Object owner = new Object();
    private Content content;
    private long revision;

    public TournamentEditor(String name, java.util.List<RoundDraft> rounds) {
        content = new Content(name, rounds);
    }
    public String name() { return content.name(); }
    public java.util.List<RoundDraft> rounds() { return content.rounds(); }
    public long revision() { return revision; }

    public void replace(String name, java.util.List<RoundDraft> rounds) {
        Content next = new Content(name, rounds);
        long nextRevision = Math.incrementExact(revision);
        content = next;
        revision = nextRevision;
    }
    public Snapshot snapshot() { return new Snapshot(owner, content); }

    public void restore(Snapshot snapshot) {
        java.util.Objects.requireNonNull(snapshot, "snapshot");
        if (snapshot.owner != owner) {
            throw new IllegalArgumentException("snapshot belongs to another editor");
        }
        long nextRevision = Math.incrementExact(revision);
        content = snapshot.content;
        revision = nextRevision;
    }
}

Both Content and RoundDraft are immutable, so snapshots safely share old content. No writable list is hiding behind an “immutable” label. Updating validates content and computes the next revision before replacing fields; restoring checks ownership first, preserving the old content on failure. This example assumes one thread and ordinary exceptions, not process-crash recovery.

Put these statements in main. Restoring content is itself a modification, so the revision increases instead of returning to an old value. Otherwise, optimistic checks could mistake “edited and undone” for “never edited.”

java
var editor = new TournamentEditor("Open", java.util.List.of());
var saved = editor.snapshot();
editor.replace("Updated", java.util.List.of());
editor.restore(saved);
System.out.println(editor.name());     // Open
System.out.println(editor.revision()); // 2

These snapshots are valid only in memory, not a cross-process import format. Complete state may be large; use measurements to decide whether incremental snapshots, command histories, or persistent checkpoints are justified.

Memento differs from Event Sourcing: Memento preserves the state of a specific moment; Event Sourcing treats domain events as authoritative facts and derives state by replaying those events. The latter requires event versioning, ordering, and an immutable log, conditions that cannot be met by simply using List<Snapshot>.

Snapshots may contain personal information, cryptographic keys, or previously deleted data, necessitating encryption, access controls, and retention policies.

Visitor: Adding New Operations to Stable Element Families ​

When the set of element types is stable but new operations are frequently added, the Visitor pattern moves operations out of element classes and preserves type-specific information through double dispatch.

java
public sealed interface Reward permits Coins, ItemReward {
    <R> R accept(RewardVisitor<R> visitor);
}

public interface RewardVisitor<R> {
    R visitCoins(Coins coins);
    R visitItem(ItemReward item);
}

public record Coins(long amount) implements Reward {
    public Coins {
        if (amount < 0) throw new IllegalArgumentException("negative coins");
    }
    @Override
    public <R> R accept(RewardVisitor<R> visitor) {
        return visitor.visitCoins(this);
    }
}

public record ItemReward(String sku, int quantity) implements Reward {
    public ItemReward {
        if (sku == null || sku.isBlank() || quantity < 1) {
            throw new IllegalArgumentException("invalid item reward");
        }
    }
    @Override
    public <R> R accept(RewardVisitor<R> visitor) {
        return visitor.visitItem(this);
    }
}

public final class RewardText implements RewardVisitor<String> {
    @Override
    public String visitCoins(Coins coins) { return "coins:" + coins.amount(); }
    @Override
    public String visitItem(ItemReward item) {
        return "item:" + item.sku() + ":" + item.quantity();
    }
}

Coins represents integer game coins, and RewardText produces descriptive text. new Coins(100).accept(new RewardText()) returns coins:100; new ItemReward("key", 2) returns item:key:2. The first dispatch selects the element’s accept, which calls the corresponding visitor method with the concrete element type. New valuation, export, or audit visitors do not require changes to element dispatch code.

The trade-off is reversed: adding a new Reward requires modifying all existing Visitors. If element types are frequently added while operations remain few, it's better to keep behavior within the element classes. Java's sealed types with exhaustive pattern matching offer a more concise alternative for external operations. The choice ultimately depends on language version, module boundaries, and the direction of future expansion.

Visitors should not require elements to expose all private fields. Elements should provide only stable, operation-specific queries, maintaining encapsulation.

Interpreter: Building a Controlled Evaluator for Finite Grammars ​

When business logic requires expressing rules, such as:

text
level >= 18 AND region IN ("ap", "eu")

don’t hand the string to a generic eval. A controlled interpreter typically follows four steps:

text
Characters -> Tokens -> AST -> Evaluate in a bounded context
java
public record EligibilityContext(int level, String region) {
    public EligibilityContext {
        if (level < 0) throw new IllegalArgumentException("negative level");
        java.util.Objects.requireNonNull(region, "region");
    }
}

public sealed interface EligibilityExpr permits And, MinimumLevel, RegionIn {
    boolean evaluate(EligibilityContext context);
}

public record And(EligibilityExpr left, EligibilityExpr right)
        implements EligibilityExpr {
    public And {
        java.util.Objects.requireNonNull(left, "left");
        java.util.Objects.requireNonNull(right, "right");
    }
    @Override
    public boolean evaluate(EligibilityContext context) {
        return left.evaluate(context) && right.evaluate(context);
    }
}

public record MinimumLevel(int level) implements EligibilityExpr {
    public MinimumLevel {
        if (level < 0) throw new IllegalArgumentException("negative level");
    }
    @Override
    public boolean evaluate(EligibilityContext context) {
        return context.level() >= level;
    }
}

public record RegionIn(java.util.Set<String> regions) implements EligibilityExpr {
    public RegionIn { regions = java.util.Set.copyOf(regions); }
    @Override
    public boolean evaluate(EligibilityContext context) {
        return regions.contains(context.region());
    }
}

This implements only AST evaluation, not a string parser. It cannot consume the earlier rule text directly. The three nodes can construct that rule manually; put these statements in main:

java
EligibilityExpr rule = new And(new MinimumLevel(18),
        new RegionIn(java.util.Set.of("ap", "eu")));
System.out.println(rule.evaluate(new EligibilityContext(18, "ap"))); // true
System.out.println(rule.evaluate(new EligibilityContext(17, "ap"))); // false
System.out.println(rule.evaluate(new EligibilityContext(18, "us"))); // false

And evaluates left to right with short-circuiting. Region matching is exact and case-sensitive; an empty region set never matches. Contexts must be nonnull, levels nonnegative, and regions and child expressions nonnull. Constructors reject invalid stored values; rule intake still needs explicit depth and complexity limits.

A production DSL must also define or enforce:

  • Maximum input size, AST depth, and execution time;
  • Whitelisted fields and functions;
  • Numeric precision, time zones, and null semantics;
  • Error location and diagnostics;
  • Grammar and rule versioning, with backward compatibility;
  • Protection against malicious regular expressions, recursion, and resource exhaustion.

When the grammar becomes moderately complex, use a mature parser generator or parsing library, rather than continually expanding split() and regular expressions. The interpreter pattern describes collaboration between the AST and evaluation context; it does not require hand-rolling a lexer or parser.

Four Pattern Selection Table ​

Change QuestionPattern
Separation of traversal mechanism and collection representationIterator
Saving and restoring an object's internal stateMemento
Stable element family with frequent external operations addedVisitor
Finite grammar requiring parsing and safe evaluationInterpreter

Reviewing a Behavioral Pattern ​

Before selecting any behavioral pattern, answer these questions:

  1. Is it the algorithm, workflow, state, notification, or traversal pattern that's changing?
  2. What are the semantics around synchronization, asynchrony, ordering, errors, and cancellation?
  3. Who owns object state and resources?
  4. Do retry, replay, or recovery operations require idempotency and versioning?
  5. Are built-in language features (such as functions, sealed switches, Iterable, or Stream) already sufficient?

References ​

Built with VitePress | Software Systems Atlas