Skip to content

10.3 Traversals, Snapshots, External Operations, and Small Languages

The final batch of behavioral challenges in the meeting hall stem from four closely related but fundamentally distinct concerns: collection traversal, undo history, syntax tree manipulation, and rule interpretation. At first glance, they appear similar, but their evolution and implementation paths diverge significantly.

These four behavioral patterns are often hidden behind the scenes in languages and standard libraries: for-each relies on iterators, 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

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();
    }
}

Consumers can iterate using for-each, without needing to know whether the internal structure is an array, a tree, or a paginated cursor.

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 behavior typically only attempts to detect concurrent modifications, it does 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 enables the originator to create opaque snapshots, which are then stored and restored by the caretaker:

java
public final class TournamentEditor {
    public record Snapshot(
            String name,
            java.util.List<RoundDraft> rounds,
            long revision) {
        public Snapshot {
            rounds = java.util.List.copyOf(rounds);
        }
    }

    public Snapshot snapshot() {
        return new Snapshot(name, rounds, revision);
    }

    public void restore(Snapshot snapshot) {
        this.name = snapshot.name();
        this.rounds = new java.util.ArrayList<>(snapshot.rounds());
        this.revision = snapshot.revision();
        checkRep();
    }
}

Snapshots should contain the complete state necessary to restore invariants and must sever mutable aliases. They may be large, so incremental snapshots, command history, or persistent checkpoints can be considered, though only after measurement.

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(Money amount) implements Reward {
    @Override
    public <R> R accept(RewardVisitor<R> visitor) {
        return visitor.visitCoins(this);
    }
}

New capabilities (such as valuation, export, or auditing) can be added as Visitor implementations without modifying the dispatch logic of each operation.

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 sealed interface EligibilityExpr {
    boolean evaluate(EligibilityContext context);
}

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

public record MinimumLevel(int level) implements EligibilityExpr {
    @Override
    public boolean evaluate(EligibilityContext context) {
        return context.level() >= level;
    }
}

The AST nodes compose the grammar, and the evaluation context exposes only permitted data.

A production DSL must also 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;
  • Malicious regular expressions, recursion, or 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

Behavior Pattern Total Check

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