An object hierarchy needs new operations added regularly, and each new operation touches every node class. The node hierarchy is stable (file, directory, symlink) but the operation set is open-ended (size, count, search, audit, export). Every new operation is Shotgun Surgery on the node hierarchy.
Each operation is a Visitor class with one visit method per node type. Node classes implement accept(visitor) and delegate dispatch via double dispatch (node.accept calls visitor.visitX(node)). New operations are new Visitor classes; node classes never change.
Before the pattern
class FileNode {constructor(name, bytes) {this.name = name;this.bytes = bytes;}totalSize() { return this.bytes; }countFiles() { return 1; }findLargest() { return this; }}class DirectoryNode {constructor(name, children) {this.name = name;this.children = children;}totalSize() {return this.children.reduce((s, c) => s + c.totalSize(), 0);}countFiles() {return this.children.reduce((s, c) => s + c.countFiles(), 0);}findLargest() {let largest = null;for (const child of this.children) {const x = child.findLargest();if (!largest || x.bytes > largest.bytes) largest = x;}return largest;}}// Adding 'searchByName' = edits in every node class. Each new operation// touches the node hierarchy; node classes change for unrelated reasons.
After the pattern
class FileNode {constructor(name, bytes) {this.name = name;this.bytes = bytes;}accept(visitor) { return visitor.visitFile(this); }}class DirectoryNode {constructor(name, children) {this.name = name;this.children = children;}accept(visitor) { return visitor.visitDirectory(this); }}class TotalSizeVisitor {visitFile(file) { return file.bytes; }visitDirectory(dir) {return dir.children.reduce((s, c) => s + c.accept(this), 0);}}class CountFilesVisitor {visitFile() { return 1; }visitDirectory(dir) {return dir.children.reduce((s, c) => s + c.accept(this), 0);}}class FindLargestVisitor {visitFile(file) { return file; }visitDirectory(dir) {let largest = null;for (const child of dir.children) {const candidate = child.accept(this);if (!largest || candidate.bytes > largest.bytes) largest = candidate;}return largest;}}const size = root.accept(new TotalSizeVisitor());const count = root.accept(new CountFilesVisitor());
Operations as node-class methods make every new operation Divergent Change on the nodes (which change for reasons unrelated to their data shape) AND Shotgun Surgery (edits ripple through every node type). The node classes become god classes accumulating every operation the system has ever wanted to perform on them.
The Visitor pattern inverts the dependency: adding a new node type now becomes Shotgun Surgery on every existing visitor — the tradeoff that prevents Visitor from being a universal answer. Choose Visitor when operations vary often and node types rarely; flip back when the opposite holds.
Per-operation reasoning is one-visitor-file; per-node reasoning is one-node-file (small, focused on data + accept). Tests for each visitor cover one operation against every node type; tests for the node hierarchy cover its accept dispatch generically.
Visitors that need state across the traversal (running totals, accumulated paths, traversal-time stack) reintroduce coupling between visit methods. Without careful state management, the visitor becomes a stateful god object the agent or human must reason about across visit calls — exactly what the pattern's per-visit-method shape was supposed to prevent.