Chain of Responsibility Pattern
Overview
Billing and technical questions have different handlers. Rather than making the intake screen know every handler rule, send the question through an ordered set of handlers.
Chain of Responsibility lets a handler accept a request or pass it to the next one. This example stops at the first match and returns an unhandled result if the chain ends without one.
UML
The caller uses the first Handler. Each handler can refer to a successor. BillingHandler and TechHandler recognize their own ticket kinds; the calling code assembles their order.
History
GoF cataloged Chain of Responsibility as a behavioral pattern in Design Patterns in 1994. A chain avoids binding the sender to one particular receiver.1
Why use it?
The intake screen stays unchanged when handler order or composition changes. Each handler owns its acceptance rule.
This differs from a pipeline that must run every step. Define an unhandled result and decide who communicates it rather than silently dropping the request.
Example
Put the billing handler before the technical handler and send one billing question.
using System;
using System.Collections.Generic;
using System.Linq;
Handler chain = new BillingHandler(new TechHandler(null));
Reply result = chain.Handle(new Ticket("billing"));
Console.WriteLine(result.Handled + ": " + result.Message);
sealed record Ticket(string Kind);
sealed record Reply(bool Handled, string Message);
abstract class Handler(Handler? next)
{
public Reply Handle(Ticket ticket)
{
Reply? result = TryHandle(ticket);
if (result is not null) return result;
// null means this handler declines the ticket, so pass it to the next handler.
// If there is no successor, return Handled=false for the whole chain.
return next?.Handle(ticket) ?? new Reply(false, "unhandled: " + ticket.Kind);
}
protected abstract Reply? TryHandle(Ticket ticket);
}
sealed class BillingHandler(Handler? next) : Handler(next)
{
protected override Reply? TryHandle(Ticket ticket) =>
ticket.Kind == "billing" ? new Reply(true, "billing desk") : null;
}
sealed class TechHandler(Handler? next) : Handler(next)
{
protected override Reply? TryHandle(Ticket ticket) =>
ticket.Kind == "technical" ? new Reply(true, "technical desk") : null;
}
True: billing deskBillingHandler accepts it, so the request does not reach TechHandler.
An extended example
Send a technical ticket and an unsupported ticket. The second returns Handled=false rather than a fabricated success.
using System;
using System.Collections.Generic;
using System.Linq;
Handler chain = new BillingHandler(new TechHandler(null));
foreach (string kind in new[] { "technical", "other" })
{
Reply result = chain.Handle(new Ticket(kind));
Console.WriteLine(result.Handled + ": " + result.Message);
}
sealed record Ticket(string Kind);
sealed record Reply(bool Handled, string Message);
abstract class Handler(Handler? next)
{
public Reply Handle(Ticket ticket)
{
Reply? result = TryHandle(ticket);
if (result is not null) return result;
// null means this handler declines the ticket, so pass it to the next handler.
// If there is no successor, return Handled=false for the whole chain.
return next?.Handle(ticket) ?? new Reply(false, "unhandled: " + ticket.Kind);
}
protected abstract Reply? TryHandle(Ticket ticket);
}
sealed class BillingHandler(Handler? next) : Handler(next)
{
protected override Reply? TryHandle(Ticket ticket) =>
ticket.Kind == "billing" ? new Reply(true, "billing desk") : null;
}
sealed class TechHandler(Handler? next) : Handler(next)
{
protected override Reply? TryHandle(Ticket ticket) =>
ticket.Kind == "technical" ? new Reply(true, "technical desk") : null;
}
True: technical desk
False: unhandled: otherOrder matters if several handlers can accept a request. This example has a finite two-step chain; dynamically assembled chains also need protection against cycles.
Related reading
Each example is a separate .NET 10 console program.
Footnotes
- Gamma, Helm, Johnson, Vlissides. Design Patterns (1994). Publisher record. Behavioral patterns, Chain of Responsibility. β©