Bridge, Visitor & Chain of Responsibility
Three more classic design patterns, each solving a distinct problem: decoupling two independently varying hierarchies, adding new operations without touching existing classes, and letting a request pass along a chain of handlers until one can process it.
Learning objectives
- Use Bridge to let two class hierarchies vary independently and avoid a combinatorial class explosion
- Explain double dispatch and implement the Visitor pattern to add operations without modifying existing classes
- Build a Chain of Responsibility to route a request through a sequence of handlers
- Identify all three patterns in real frameworks such as JDBC, compiler AST processing, and servlet filter chains
◆ The problem
Picture a universal remote control system that needs to support two remote types — a basic remote and an advanced remote — and control three device types — a TV, a radio, and an AC unit. Both the remote lineup and the device lineup are expected to keep growing independently as the product expands.
The direct approach is to model each combination as its own class: a BasicTvRemote, a BasicRadioRemote, an AdvancedTvRemote, and so on for every remote-device pairing. It compiles, and each class works correctly on its own. But look at what happens as soon as a third remote type or a fourth device type gets added.
With 5 remote types and 10 device types, this approach needs 5 x 10 = 50 separate classes. That is class explosion — the exact same shape of problem that motivates keeping decorator layers separate from what they decorate. TV-handling logic gets copy-pasted into every TV-related remote class; "advanced" logic gets copy-pasted into every advanced remote class. A single bug fix to how TVs handle power now has to be found and corrected in every TV-related remote class individually, because there is no single place that logic lives.
Work through what is actually going wrong here, one step at a time:
- "Which remote" and "which device" are two genuinely independent things that vary separately — a remote type does not determine a device type, and a device type does not determine a remote type.
- So they should not be combined into one class hierarchy at all. They should be two separate hierarchies.
- A remote does need to actually operate some device, though — so a remote should hold a reference to a device, rather than being fused with one at the class level.
That is the Bridge pattern: separate an abstraction (the remote) from its implementation (the device) by connecting the two hierarchies through composition — a reference held by the abstraction — instead of inheritance. That held reference is the "bridge" the pattern is named for.
💻 Code example
class BasicTvRemote { void powerOn() { System.out.println("TV powering on"); } void volumeUp() { System.out.println("TV volume up"); } } class BasicRadioRemote { void powerOn() { System.out.println("Radio powering on"); } void volumeUp() { System.out.println("Radio volume up"); } } class AdvancedTvRemote { void powerOn() { System.out.println("TV powering on"); } // IDENTICAL to BasicTvRemote's powerOn() void volumeUp() { System.out.println("TV volume up"); } // IDENTICAL too void mute() { System.out.println("TV muted"); } // the ONE genuinely new capability } // ...and BasicAcRemote, AdvancedRadioRemote, AdvancedAcRemote still to come
Building the fix takes three steps: an independent device hierarchy, an abstraction that composes a device instead of extending it, and independent remote types built on top of that abstraction.
First, the Device hierarchy, which knows nothing at all about remotes. Second, RemoteControl, which holds a Device reference — this field is the bridge itself, the composition link connecting the two otherwise-unrelated hierarchies. It delegates every device-facing action to the device it holds, rather than implementing device behavior itself. Third, concrete remote types built on top of RemoteControl, entirely independent of which device types exist.
Notice what neither side knows about the other. BasicRemote and AdvancedRemote never mention Tv or Radio anywhere in their own source code. Tv and Radio never know remotes exist at all. The only thing connecting the two hierarchies is the single device field on RemoteControl.
At runtime, any remote type can be paired with any device type freely, because the pairing happens through a constructor argument, not through which class was written.
The payoff is concrete and countable. With N remote types and M device types, the naive one-class-per-combination approach needs N x M classes. Bridge needs only N + M. At 5 remote types and 10 device types, that is 50 classes against 15 — and the gap only widens as either dimension keeps growing.
💻 Code example
interface Device { void powerOn(); void setVolume(int level); } class Tv implements Device { public void powerOn() { System.out.println("TV powering on"); } public void setVolume(int level) { System.out.println("TV volume set to " + level); } } class Radio implements Device { public void powerOn() { System.out.println("Radio powering on"); } public void setVolume(int level) { System.out.println("Radio volume set to " + level); } } abstract class RemoteControl { protected final Device device; // composition, not inheritance -- connects the two hierarchies RemoteControl(Device device) { this.device = device; } void powerOn() { device.powerOn(); } // delegates -- RemoteControl never implements device behavior itself void volumeUp() { device.setVolume(1); } } class BasicRemote extends RemoteControl { BasicRemote(Device device) { super(device); } } class AdvancedRemote extends RemoteControl { AdvancedRemote(Device device) { super(device); } void mute() { device.setVolume(0); } // the ONE genuinely new capability, written exactly once } public class Main { public static void main(String[] args) { RemoteControl basicTvRemote = new BasicRemote(new Tv()); basicTvRemote.powerOn(); // "TV powering on" basicTvRemote.volumeUp(); // "TV volume set to 1" AdvancedRemote advancedRadioRemote = new AdvancedRemote(new Radio()); advancedRadioRemote.powerOn(); // "Radio powering on" advancedRadioRemote.mute(); // "Radio volume set to 0" } }
▲ Common mistake
Do not reach for Bridge when a system will only ever have one kind of device, or one kind of remote. It adds a real layer of indirection — an extra interface, an extra composition link — for no actual flexibility gained. The pattern earns its cost specifically when both dimensions genuinely vary independently and are expected to keep growing.
▲ Common mistake
Bridge and Strategy are genuinely easy to confuse, because structurally, a class holding a reference to an interface it delegates to looks identical either way. The distinction is about intent, not shape: Strategy is about swapping one algorithm out for a single class — there is one hierarchy, and the strategy interface is the only thing that varies. Bridge is about letting two entire class hierarchies, each with their own subclasses, vary independently of each other. If you find yourself with two dimensions of variation instead of one, that is the signal you are looking at Bridge, not Strategy.
Bridge is not a textbook-only pattern — it is load-bearing infrastructure in real systems. JDBC is a textbook example of it in production: the JDBC API is the abstraction that application code is written against, and each database vendor — MySQL, PostgreSQL, Oracle — provides its own driver as the implementation. Application code switches from one database to another by swapping the driver dependency, without touching the code that calls JDBC methods, because the abstraction and the implementation were deliberately kept as two separate hierarchies connected by composition.
Cross-platform GUI toolkits bridge a platform-independent widget API to OS-specific rendering code the same way — a button's public API stays the same across Windows, macOS, and Linux, while the actual drawing code underneath is swapped per platform.
◆ The problem
A document management system needs to support several document types — PDF, Word, plain text — each of which needs to support several operations: exporting, printing, compressing, and more operations expected to arrive over time as the product grows.
The obvious approach is to give every document class one method per operation. It works, and every document supports every operation correctly. Then the product team asks for a fourth operation: validation.
Adding that one operation means editing every existing document class — three already-tested classes, all reopened for one new feature. With 3 document types and 5 operations, that is already 15 methods scattered across 3 classes; grow to 5 types and 10 operations and it becomes 50 methods. Worse, the document classes themselves — which should represent document data and structure — are now bloated with operation logic that has nothing to do with what a document is, only with what can be done to it.
Work through what is actually wrong:
- An operation like "export" is really one coherent piece of logic that happens to vary per document type. Conceptually, that is one thing — an exporter — not three unrelated methods bolted onto three unrelated classes.
- So each operation should be pulled out into its own class, the same instinct behind extracting an algorithm into its own strategy class — except here, the "algorithm" needs to behave differently depending on which concrete document type it has been handed.
- Each document needs a way to hand itself to an operation, correctly typed, rather than the operation guessing or checking the document's type itself.
That is the Visitor pattern: pull operations out into their own hierarchy of visitor classes, and give every document type one small method — accept(Visitor) — that hands itself to the visitor, letting the visitor decide what to do based on the document's real, concrete type.
💻 Code example
class PDFDocument { void export() { System.out.println("Exporting PDF"); } void print() { System.out.println("Printing PDF"); } void compress() { System.out.println("Compressing PDF"); } } class WordDocument { void export() { System.out.println("Exporting Word doc"); } void print() { System.out.println("Printing Word doc"); } void compress() { System.out.println("Compressing Word doc"); } } // TextDocument repeats the identical three-method shape again
Two interfaces make up the pattern. DocumentVisitor declares one overloaded visit() method per document type. Document declares just one method, accept(DocumentVisitor), that every document type must implement.
Each document's accept() implementation does exactly one thing: it calls visitor.visit(this). Because this has a statically known, concrete type at that exact line — inside PDFDocument.accept(), this is known to be a PDFDocument — the compiler picks the matching visit(PDFDocument) overload specifically, never visit(WordDocument) or visit(TextDocument).
A concrete visitor, like ExportVisitor, then implements all three visit() overloads, holding all of the export logic for every document type in one coherent class.
It is worth tracing exactly what happens on a call like pdf.accept(exporter), since this is the one part of Visitor that genuinely rewards walking through slowly:
accept()is called on aDocumentreference, but the JVM dispatches toPDFDocument.accept()specifically, based on the object's real runtime type. That is the first dispatch — ordinary polymorphism, the same mechanism behind every virtual method call.- Inside
PDFDocument.accept(), the call isvisitor.visit(this), and becausethisis statically known at that line to be aPDFDocument, the compiler picks thevisit(PDFDocument)overload specifically. That is the second dispatch.
Two dispatches — one on the document's real type, one on the matching visit() overload — is exactly what "double dispatch" means. It is the entire mechanism that lets ExportVisitor run different logic per document type without a single instanceof check anywhere in the code.
💻 Code example
interface DocumentVisitor { // one visit() method PER document type void visit(PDFDocument doc); void visit(WordDocument doc); void visit(TextDocument doc); } interface Document { // every document supports exactly this one method void accept(DocumentVisitor visitor); } class PDFDocument implements Document { public void accept(DocumentVisitor visitor) { visitor.visit(this); // calls the visit(PDFDocument) overload specifically -- this is "double dispatch" } } class WordDocument implements Document { public void accept(DocumentVisitor visitor) { visitor.visit(this); } } class TextDocument implements Document { public void accept(DocumentVisitor visitor) { visitor.visit(this); } } class ExportVisitor implements DocumentVisitor { public void visit(PDFDocument doc) { System.out.println("Exporting PDF"); } public void visit(WordDocument doc) { System.out.println("Exporting Word doc"); } public void visit(TextDocument doc) { System.out.println("Exporting text doc"); } } public class Main { public static void main(String[] args) { Document pdf = new PDFDocument(); DocumentVisitor exporter = new ExportVisitor(); pdf.accept(exporter); // "Exporting PDF" } }
▲ Common mistake
Visitor makes a genuine trade-off, and it is worth being honest about both sides of it. Adding a new operation is now trivial — a new class implementing DocumentVisitor, with zero changes to PDFDocument, WordDocument, or TextDocument. But adding a new document type is now genuinely expensive: it means adding a new method to the DocumentVisitor interface, which forces every existing visitor — ExportVisitor and anything else implementing it — to add a matching overload, or the code will not compile. This is precisely the inverse of the trade-off ordinary polymorphism usually makes: normally, new types are easy and new operations mean touching every type. Reach for Visitor specifically when the set of document types is stable and new operations are the thing that keeps arriving.
Compilers use Visitor extensively for traversing an abstract syntax tree. The tree of statements and expressions stays stable once parsing is done, while operations like type-checking, code generation, and optimization are each implemented as a separate visitor walking the same tree. This keeps each compiler pass in its own self-contained class instead of scattering type-checking logic, codegen logic, and optimization logic across every single node type in the tree.
Java's own java.nio.file.FileVisitor interface applies the exact same shape to walking a file system: preVisitDirectory, visitFile, postVisitDirectory, and visitFileFailed are all callback methods a visitor implements, and Files.walkFileTree() calls the right one as it traverses. File-system tools like search utilities, backup software, and antivirus scanners all fit this pattern naturally — the directory structure stays the same, and different tools are really just different visitors walking it.
◆ The problem
A customer support system needs to route requests through escalating levels: a request first goes to a support agent; if they cannot resolve it, it escalates to a supervisor; if the supervisor cannot either, to a manager; and finally to a director. The request itself should not need to know, in advance, which level will actually end up handling it.
The direct approach is one function with a chain of conditionals, branching on request severity to decide which level handles it. This works for four levels and one routing rule. Then the business asks for request type to also affect routing — billing issues, say, should always go straight to a supervisor regardless of severity — on top of severity.
Every new routing rule, whether it is a new handler level or a new condition based on request type, means editing this one already-tested method, adding another branch to an already-long conditional chain. The routing logic and the actual handling logic are tangled together in one place, and the method has to know about every handler level that exists, all at once, just to make one routing decision.
Work through what should change:
- Each support level's actual job — "can I resolve this request?" — is genuinely self-contained, and should not need to live inside one giant function that also knows about every other level's rules.
- Each level only needs to know one thing beyond its own logic: who to hand the request off to, if it cannot handle it. Not the entire chain, not every other level's rules.
- So each level should be its own object, holding a reference to the next one, deciding for itself whether to handle a request or pass it along.
That is the Chain of Responsibility pattern: a chain of handler objects, each holding a reference to the next, where a request moves along the chain until some handler capable of processing it actually does.
💻 Code example
class SupportSystem { void handleRequest(Request request) { if (request.severity() <= 1) { System.out.println("Handled by Support Agent"); } else if (request.severity() <= 3) { System.out.println("Handled by Supervisor"); } else if (request.severity() <= 5) { System.out.println("Handled by Manager"); } else { System.out.println("Handled by Director"); } } }
An abstract SupportHandler holds a reference to the next handler in the chain, and declares one abstract method, handleRequest(). Notice it only stores a reference to the next handler, not the whole chain — each link genuinely does not know or care what happens beyond its immediate neighbor.
Each concrete handler decides only for itself: check if it can handle the request, and if not, pass it along to next, without knowing or caring who comes after that. SupportAgent checks severity up to 1; Supervisor checks up to 3; Manager and Director follow the identical shape with their own thresholds.
Assembling the chain happens once, separately from using it — each handler's setNext() is called to link agent to supervisor to manager to director. After that, the caller always talks to the first link, agent, and never needs to know or decide which level actually ends up handling a given request. That decision emerges from the chain itself, one delegation at a time, as the request moves from handler to handler until one of them accepts it.
💻 Code example
abstract class SupportHandler { protected SupportHandler next; // a reference to the NEXT handler, not the whole chain void setNext(SupportHandler next) { this.next = next; } abstract void handleRequest(Request request); } class SupportAgent extends SupportHandler { void handleRequest(Request request) { if (request.severity() <= 1) { System.out.println("Handled by Support Agent"); } else if (next != null) { next.handleRequest(request); // can't handle it, so pass it ALONG -- no idea who's after "next" } } } class Supervisor extends SupportHandler { void handleRequest(Request request) { if (request.severity() <= 3) { System.out.println("Handled by Supervisor"); } else if (next != null) { next.handleRequest(request); } } } // Manager and Director follow the identical shape, with their own severity thresholds public class Main { public static void main(String[] args) { SupportHandler agent = new SupportAgent(); SupportHandler supervisor = new Supervisor(); SupportHandler manager = new Manager(); SupportHandler director = new Director(); agent.setNext(supervisor); // building the chain: agent -> supervisor -> manager -> director supervisor.setNext(manager); manager.setNext(director); agent.handleRequest(new Request(4)); // "Handled by Manager" -- escalated past agent and supervisor automatically } }
▲ Edge case
What happens if nobody in the chain can handle a request? Director.handleRequest(), as the last link, needs an explicit fallback for a request too severe for anyone in the chain to accept — silently doing nothing would lose the request entirely. A well-built chain either guarantees its last handler accepts everything by default, or explicitly signals "unhandled" back to the caller so the failure is visible.
▲ Edge case
Chain order is a real design decision, not an implementation detail. Unlike additive layering, where stacking order often does not change the end result, a Chain of Responsibility's order directly determines outcomes — routing billing requests through a technical specialist first versus last produces genuinely different results. Building the chain is exactly where that decision gets made, and it deserves the same care as the handler logic itself.
Web framework middleware is a live, running Chain of Responsibility. Express.js middleware, Spring MVC interceptors, and ASP.NET Core middleware all follow the identical shape: each piece of middleware either handles or rejects a request, or passes it to the next one in line, without needing to know what the rest of the chain looks like. A servlet filter chain in Java web applications works the same way — each Filter calls chain.doFilter() to pass control forward, or short-circuits the chain by not calling it.
Exception handling in Java, C#, and Python follows the identical shape: an exception propagates up a chain of catch blocks or handlers until one of them catches it, exactly the way a support request propagates up a chain of handlers until one of them resolves it.
Q: What genuine problem does Bridge solve, structurally, and with what concrete numeric payoff? A: It prevents a combinatorial subclass explosion when two independent hierarchies need to vary separately, by connecting them through composition instead of one class per combination. That turns N x M classes into N + M -- 50 into 15, for 5 remote types and 10 device types.
Q: What is the difference between Bridge and Strategy, given how similar the two can look structurally? A: Strategy swaps one algorithm for a single class. Bridge lets two entire class hierarchies, each with their own subclasses, vary independently of each other. Two independent dimensions of variation is the signal for Bridge; one dimension is the signal for Strategy.
Q: What does double dispatch mean in the Visitor pattern, concretely? A: Two dispatches happen in sequence. First, ordinary polymorphism picks the correct accept() implementation based on the document's real runtime type. Second, method overload resolution inside that accept() picks the matching visit() overload based on the statically known type of "this" at that exact call site.
Q: What trade-off does Visitor make, compared to ordinary polymorphism? A: It makes adding a new operation easy -- a new visitor class, zero changes to existing document types -- but makes adding a new document type hard, since every existing visitor needs a new overload added. That is the inverse of typical polymorphism's trade-off.
Q: In Chain of Responsibility, does a request's sender know in advance which handler will actually process it? A: No. The request simply moves along the chain until some handler capable of processing it does. The sender only ever talks to the first handler in the chain.
Q: Why does chain order matter more in Chain of Responsibility than layering order typically does when stacking additive behavior? A: Purely additive layers often do not change the end result regardless of order. Chain of Responsibility's handlers each make a routing decision, so which handler sees a request first can change which one ultimately handles it -- order is a real design choice, not an implementation detail.
Q: Why can Bridge and Chain of Responsibility both be described as replacing conditional branching with objects that hold references to collaborators? A: Both patterns take logic that would otherwise live in one branching method -- which combination of remote and device, or which severity threshold applies -- and distribute it across small objects connected by references, so each object only needs to know about its immediate collaborator rather than the whole decision space.
Q: If a new document type needs to be added to a Visitor-based system, what specifically breaks, and why doesn't that same problem occur when adding a new device type in a Bridge-based system? A: Adding a new document type forces every existing visitor implementation to add a new overload or fail to compile, because the visitor interface itself must grow. Adding a new device type in Bridge requires no changes to the remote hierarchy at all, because remotes depend only on the Device interface's existing methods, not on an exhaustive list of every concrete device type.
Want a visual for this concept?
Generate a diagram tailored to “Bridge, Visitor & Chain of Responsibility” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →