Design Pattern - The Visitor

I ran into this pattern while looking to use CDK for Terraform to configure Azure infrastructure. It allows class instances of varying shapes and sizes to have operations performed on them by external parties (visitors) in a way that allows neither side to know a whole lot about the other.

It feels like a spiritual, inverted relative to mediator/middleware patterns: in this pattern the entity at play receives the visitor, instead of a middleware function receiving an entity.

Example Scenario #

Say that you have multiple entities that are already embedded in your system that have no common interface, but need a new common operation performed on them. Our scenario for demonstration is a requirement to audit the names of all different types of entities in a university record system.

We'll start with the entities:


class Student {
constructor(
public firstName: string,
public lastName: string,
) {}
}

class Professor {
constructor(
public name: string
) {}
}

As you can see, even the small sample set doesn't have a common attribute to make this easy. Though we'd like to go back and correct this before these structures were deeply embedded, time machines are still unfairly priced in today's market.

In this scenario, changing the professor or student would require significant refactoring. Adding normalization logic to either class (i.e. a getter for name on the Student that concatenates first/last name) is possibly going to pollute and confuse things further. Neither effort has a clear long term benefit beyond our current auditing case.

Your scenario might look the same, it might not! Life is complicated. There are many ways to solve many things, but here's how you might solve it with the visitor pattern.

Welcoming the Visitor #

There are 2 key parts that make up this pattern and make it identifiable in the wild:

  • Target entity classes are extended to expose an accept() method that accepts a visitor instance.
  • A visitor class with a standardized visit() method is invoked by the target entity in their accept() method.

This does require extension of our entities, but does not change any existing implementation. Since it requires no refactors, and feels SOLID, I'm sold.

Some patterns are quite rigid, but in this case there are 3 variants I found for this pattern as I worked with and explored it. As expected, all the variants come with different tradeoffs.

I've outlined the variants below, and provided more detailed examples further on. Just know that the names below are completely made up, and that there might be better ones someone smarter made up before me:

/**
* Stranger variant
*/

interface IVisitor {
visit(entity: unknown): void;
}

/**
* Census Worker variant
*/

interface VisitInfo {
name: string;
}
interface IVisitor {
visit(info: VisitInfo): VisitInfo;
}

/**
* Neighbour variant
*/

interface SplitNamePerson {
firstName: string;
lastName: string;
}

interface JoinedNamePerson {
name: string;
}

interface IVisitor {
visitSplitNamePerson(person: SplitNamePerson): void;
visitJoinedNamePerson(person: JoinedNamePerson): void;
}

These interfaces are all similar, but the devil is in the details. It's easier to explain once you see each in more detail.

Variant 1. Stranger Visitor #

The Stranger variant of the Visitor pattern protects the visited Entity from needing to know anything about the Visitor, and puts all of the responsibility on the Visitor to determine if and how to handle the entity's structure. The control is completely with the Visitor, not the Entity.

This can be good for:

  • when you want a standardized, extensible visitor interface that solves for unknown future cases
  • when you want to do direct manipulation of an entity and want direct access to the instance
  • when you need a lot of different visitors to perform operations against a small set of entity types

The tradeoffs:

  • the visitor needs a way to determine if it can visit that entity successfully, or to have a dictionary of supported entities, or both.

Here is an example for our scenario:

/**
* Stranger variant
*/

interface IVisitor {
visit(entity: unknown): void;
}

// Our entities
class Student {
constructor(
public firstName: string,
public lastName: string,
) {}

accept(visitor: IVisitor) {
visitor.visit(this);
}
}

class Professor {
constructor(
public name: string
) {}

accept(visitor: IVisitor) {
visitor.visit(this);
}
}

/**
* audits the name of a person in the school system for invalid characters
*/

class NameAuditVisitor implements IVisitor {
invalidItems: string[] = [];

audit(name: string): void {
if(name.includes('@')) this.invalidItems.push(name);
}

visit(entity: unknown): void {
if(entity instanceof Student) {
this.audit([entity.firstName, entity.lastName]. join(' '));
return;
}

if(entity instanceof Professor) {
this.audit(entity.name);
return;
}

throw new Error('unsupported entity visited.');
}
}

const auditVisitor = new NameAuditVisitor();
const elem1 = new Student('B@d', 'McB@dderson');
const elem2 = new Professor('Dr. Ulrich von Liechtenstein');

elem1.accept(auditVisitor);
elem2.accept(auditVisitor);

console.log(auditVisitor.invalidItems); // [ 'B@d McB@dderson' ]

Variant 2. Census Worker Visitor #

The Census Worker variant of the Visitor pattern accepts a fixed data model, allowing the Entity to determine how to provide that data based on it's own structure. Neither the Entity or the Visitor need to know much about each other, just that the visitor is expecting certain data before going on it's way.

There is one key difference from the first variant - the visit method returns the same data model as it receives. This allows for manipulation on the information that the entity can decide to apply back to itself or ignore. The control is completely with the Entity, not the Visitor.

This can be good for:

  • when the necessary information across visitor use-cases is fairly constrained and well established
  • many possible entities with extremely variable structures and few visitor types
  • a good fit for environments where the standard is that the entity is responsible for generating it's own outputs for various situations

The tradeoffs:

  • Extensibility for future use-cases can be uncertain, and possibly limits the interoperability.
  • Direct manipulation of the Entity instance is a bit more tedious, though it is possible and potentially even more desirable depending on your preference.
  • Whenever the VisitInfo interface changes, there is more coordination necessary between the two sides.

Here is an example for our scenario:

/**
* Census Worker variant
*/

interface VisitInfo {
name: string;
}
interface IVisitor {
visit(info: VisitInfo): VisitInfo;
}


// Our entities
class Student {
constructor(
public firstName: string,
public lastName: string,
) {}

accept(visitor: IVisitor) {
const studentName = [this.firstName, this.lastName].join(' ');
const { name } = visitor.visit({ name: studentName });
const [firstName = '', lastName = ''] = name.split(' ');
this.firstName = firstName;
this.lastName = lastName;
}
}

class Professor {
constructor(
public name: string
) {}

accept(visitor: IVisitor) {
const { name } = visitor.visit({ name: this.name });
this.name = name;
}
}

/**
* audits the name of a person in the school system for invalid characters
*/

class NameAuditVisitor implements IVisitor {
invalidItems: string[] = [];

audit(name: string): void {
if(name.includes('@')) this.invalidItems.push(name);
}

visit(info: VisitInfo): VisitInfo {
this.audit(info.name)
return { ...info };
}
}

const auditVisitor = new NameAuditVisitor();
const elem1 = new Student('B@d', 'McB@dderson');
const elem2 = new Professor('Dr. Ulrich von Liechtenstein');

elem1.accept(auditVisitor);
elem2.accept(auditVisitor);

console.log(auditVisitor.invalidItems); // [ 'B@d McB@dderson' ]

Variant 3. Neighbour Visitor #

The Neighbour variant doesn't expose a single visit method as it's interface, rather it provides multiple case-specific methods. This splits some of the responsibility between the Entity and the Visitor - the Entity picks the method that is suited to it, and the Visitor does the rest.

This can be good for:

  • When visitors are designed for initial use cases but have to be extended to support new entities
  • When there is a fairly small set of possible interface variants that the visitor has to support across a large number of concrete entity classes

The tradeoffs:

  • Aligning the interfaces can be tricky, depending on the language and inference capabilities
  • It requires good understanding of the interfaces to enable data manipulation.
  • Similar to the Census worker, it has increased overhead on coordinating future extensions between both the visitor and the entity.

Here is an example for our scenario:

/**
* Neighbour variant
*/

interface SplitNamePerson {
firstName: string;
lastName: string;
}

interface JoinedNamePerson {
name: string;
}

interface IVisitor {
visitSplitNamePerson(person: SplitNamePerson): void;
visitJoinedNamePerson(person: JoinedNamePerson): void;
}

// Our entities
class Student {
constructor(public firstName: string, public lastName: string) {}

accept(visitor: IVisitor) {
visitor.visitSplitNamePerson(this);
}
}

class Professor {
constructor(public name: string) {}

accept(visitor: IVisitor) {
visitor.visitJoinedNamePerson(this);
}
}

/**
* audits the name of a person in the school system for invalid characters
*/

class NameAuditVisitor implements IVisitor {
invalidItems: string[] = [];

audit(name: string): void {
if (name.includes('@')) this.invalidItems.push(name);
}

visitSplitNamePerson(person: SplitNamePerson): void {
this.audit([person.firstName, person.lastName]. join(' '));
}

visitJoinedNamePerson(person: JoinedNamePerson): void {
this.audit(person.name);
}
}

const auditVisitor = new NameAuditVisitor();
const elem1 = new Student('B@d', 'McB@dderson');
const elem2 = new Professor('Dr. Ulrich von Liechtenstein');

elem1.accept(auditVisitor);
elem2.accept(auditVisitor);

console.log(auditVisitor.invalidItems); // [ 'B@d McB@dderson' ]

That summarizes the Visitor Pattern as I understand it, I hope it's a valuable starting point for someone out there. I wrote this post as a means to deepen my understanding, and would love to know if I missed or misunderstood anything key to it.

Until next time!