As software engineers, we often face a dilemma: we have a stable set of classes, but we constantly need to add new functionality that operates on them. Do we keep modifying these existing, stable classes? That can be risky and violates the Open/Closed Principle. Or is there a cleaner way?
Enter the Visitor Design Pattern. It’s a behavioral pattern that lets you add new operations to a set of objects without changing the classes of those objects. It’s like hiring a specialist to work on your existing equipment, rather than trying to rebuild the equipment every time you need a new task done.
In this guide, we’ll break down the Visitor pattern, understand the problem it solves, and walk through a practical Java example.
The Core Problem: Scattered Logic
Imagine you’re building an e-commerce platform. You have a shopping cart that can hold different types of items. Let’s keep it simple and say you have Book and Electronics items.
// Common interface for items in our cart
public interface CartItem {
// ... common methods
}
public class Book implements CartItem {
private double price;
private double weight;
// constructor, getters...
}
public class Electronics implements CartItem {
private double price;
private double weight;
private boolean fragile;
// constructor, getters...
}
This structure is clean and simple. But now, the business team wants to calculate shipping costs. The logic is different for each item type:
- Books: Shipping is based on weight.
- Electronics: Shipping is based on weight, with an extra fee if it’s fragile.
The most straightforward approach is to add a calculateShippingCost() method to each class:
public interface CartItem {
double calculateShippingCost();
}
public class Book implements CartItem {
// ... other fields
@Override
public double calculateShippingCost() {
return weight * 1.5; // $1.5 per kg
}
}
public class Electronics implements CartItem {
// ... other fields
@Override
public double calculateShippingCost() {
double cost = weight * 2.5; // $2.5 per kg
if (fragile) {
cost += 5.0; // Extra fragile fee
}
return cost;
}
}
This works. But what happens next week when the business team wants to add a new operation, like generating an XML export for items? Or calculating environmental tax? We’d have to go back and modify Book and Electronics again. This approach leads to:
- Violation of the Open/Closed Principle: Our core model classes are constantly being modified.
- Scattered Logic: The logic for a single operation (like “shipping cost”) is spread across multiple classes.
- Maintenance Nightmare: Adding a new item type (e.g., Groceries) means we have to remember to implement *every* one of these special methods.
The Solution: The Visitor Pattern
The Visitor pattern elegantly solves this by separating the operations from the objects they operate on. It involves two key hierarchies:
- The Elements (Visitable): These are the objects we want to operate on (e.g., Book, Electronics). They are stable and don’t change often.
- The Visitors (Visitor): These represent the operations we want to perform (e.g., ShippingCostCalculator, XmlExporter). We can add new visitors anytime without touching the elements.
Key Components of the Pattern
- Visitor (Interface): Declares a visit() method for each type of concrete element. For example, visit(Book book) and visit(Electronics electronics).
- ConcreteVisitor: Implements the Visitor interface and contains the actual logic for an operation. For instance, ShippingCostCalculator.
- Visitable / Element (Interface): Declares an accept() method that takes a Visitor as an argument.
- ConcreteVisitable / ConcreteElement: Implements the Visitable interface. Its job is simply to call the visitor’s visit() method, passing itself as the argument (visitor.visit(this)).
Let’s Refactor Our Shopping Cart Example
Step 1: Define the Visitor and Visitable Interfaces
First, we create a ShoppingCartVisitor interface. It has a specific visit method for each element type it can handle.
// The Visitor interface
public interface ShoppingCartVisitor {
double visit(Book book);
double visit(Electronics electronics);
}
Next, we define the ItemElement (our “Visitable”) interface. It has a single method, accept().
// The Visitable interface
public interface ItemElement {
double accept(ShoppingCartVisitor visitor);
}
Step 2: Update the Concrete Elements to Accept a Visitor
Now, we modify our Book and Electronics classes. We remove the calculateShippingCost() method and instead implement ItemElement. The accept method is a key piece of the magic—it enables a technique called “double dispatch.”
public class Book implements ItemElement {
private double price;
private double weight;
// constructor, getters...
@Override
public double accept(ShoppingCartVisitor visitor) {
// This 'this' is known to be a Book at compile time.
// It calls the correct visit(Book book) method.
return visitor.visit(this);
}
public double getWeight() { return weight; }
public double getPrice() { return price; }
}
public class Electronics implements ItemElement {
private double price;
private double weight;
private boolean fragile;
// constructor, getters...
@Override
public double accept(ShoppingCartVisitor visitor) {
// This 'this' is known to be an Electronics item.
// It calls the correct visit(Electronics electronics) method.
return visitor.visit(this);
}
public double getWeight() { return weight; }
public double getPrice() { return price; }
public boolean isFragile() { return fragile; }
}
Notice how clean these classes are now. They contain only their own data and a single accept method. They have no knowledge of shipping, taxes, or any other external operation.
Step 3: Create a Concrete Visitor for Our Operation
Here’s where the shipping logic now lives—all in one place. We create a ShippingCostCalculator that implements our visitor interface.
// A Concrete Visitor
public class ShippingCostCalculator implements ShoppingCartVisitor {
@Override
public double visit(Book book) {
// Shipping logic for books
double cost = book.getWeight() * 1.5;
System.out.println("Book shipping cost: " + cost);
return cost;
}
@Override
public double visit(Electronics electronics) {
// Shipping logic for electronics
double cost = electronics.getWeight() * 2.5;
if (electronics.isFragile()) {
cost += 5.0; // Extra fragile fee
}
System.out.println("Electronics shipping cost: " + cost);
return cost;
}
}
All the shipping logic is now neatly encapsulated in a single class.
Step 4: Putting It All Together (The Client Code)
The client code now creates a list of items and a visitor. It then iterates through the items and asks each one to “accept” the visitor.
public class ShoppingCartClient {
public static void main(String[] args) {
ItemElement[] items = new ItemElement[]{
new Book(20, 1.2), // price, weight
new Electronics(200, 3.5, true), // price, weight, fragile
new Book(15, 0.8)
};
ShoppingCartVisitor shippingVisitor = new ShippingCostCalculator();
double totalShippingCost = 0;
for (ItemElement item : items) {
totalShippingCost += item.accept(shippingVisitor);
}
System.out.println("======================================");
System.out.println("Total Shipping Cost = " + totalShippingCost);
}
}
When you run this, the magic of double dispatch happens: 1. item.accept(visitor) is called. At this point, the program knows the concrete type of item (e.g., Book). 2. The Book’s accept method calls visitor.visit(this). 3. Because this is a Book, the JVM selects the visit(Book book) method on the shippingVisitor. The correct logic is executed without any if/else or instanceof checks!
Adding a New Operation is Easy!
Now, if the business team wants an XML export, we don’t touch Book or Electronics at all. We just create a new visitor:
// Another Concrete Visitor for a completely new operation
public class XmlExportVisitor implements ShoppingCartVisitor {
// For simplicity, let's pretend our visitor returns a String now.
// In a real app, the visitor interface might be generic or return a common type.
// To adapt, let's assume the visitor is void and just prints.
// public interface ShoppingCartVisitor { void visit(...); }
public String export(ItemElement[] items) {
StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
sb.append("<items>");
for (ItemElement item : items) {
// This is a simplified example. A real implementation would use the accept method.
// Let's stick to the original example for clarity.
}
sb.append("</items>");
return sb.toString();
}
// The proper way would be a new visitor interface or adapting the existing one.
// Let's create DataExportVisitor for clarity
}
A better way would be to create a new visitor that performs this action. The key is that the ItemElement classes stay untouched.
When to Use the Visitor Pattern?
The Visitor pattern is powerful but also specific. Use it when:
- You have a complex object structure with many classes of objects.
- You need to perform many different and unrelated operations on these objects.
- The classes that make up the object structure rarely change, but you frequently need to add new operations.
- You want to keep the business logic for an operation consolidated in one place.
Pros and Cons
Pros
- Follows the Open/Closed Principle: You can add new operations (visitors) without modifying the element classes.
- Separation of Concerns: The logic for an operation is centralized in its visitor, not scattered across element classes.
- Clean Element Classes: Your elements (Book, Electronics) remain focused on their primary responsibilities.
- Can Accumulate State: The visitor can aggregate information as it traverses the object structure (like our totalShippingCost).
Cons
- Hard to Add New Elements: If you need to add a new ItemElement (e.g., Groceries), you must update every single visitor to add a new visit(Groceries groceries) method. This is the pattern’s main drawback.
- Breaks Encapsulation: The visitor often needs to call public methods on the element to get data for its operations (.getWeight(), .isFragile()). This can expose more of the element’s internal state than you might like.
Conclusion
The Visitor Design Pattern is a fantastic tool for separating algorithms from the objects they operate on. It shines when you have a stable object model but a volatile set of operations. By centralizing operational logic into visitors, you create a system that’s cleaner, easier to maintain, and more extensible for future requirements.
The next time you find yourself adding yet another method to a group of classes for a new feature, take a step back and consider if a specialist—a Visitor—could do the job better.