Chain of Responsibility Pattern


  • Description: Pass a request along a chain of handlers — each decides to handle or forward, decoupling sender from receiver and avoiding monolithic if/else dispatch.
  • My Notion Note ID: K2C-2-13
  • Created: 2026-05-22
  • Updated: 2026-05-22
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Intent

  • Decouple sender of a request from its receiver by chaining candidate handlers.
  • Each handler — handle the request OR forward to the next.
  • Replaces a giant switch / if-else if chain with composable links.
  • Caller doesn't know which handler will respond — or whether any will.

Typical uses:

  • GUI event bubbling — child widget → parent → window → application.
  • HTTP middleware — auth → logging → rate-limit → router.
  • Logging filters — debug → info → warn → error sinks.
  • Approval workflow — team lead → manager → director, by amount.
  • Exception handling — language-level CoR (catch frames up the call stack).

2. Structure

Roles:

  • Handler — interface declaring handle(request) + reference to next.
  • ConcreteHandler — implements handle; either consumes request or delegates to next.
  • Client — builds chain, submits request to head.
Client → [HandlerA] → [HandlerB] → [HandlerC] → nullptr
            │ handle?    │ handle?    │ handle?
            └─ forward ──┴─ forward ──┴─ done / unhandled

Forwarding contract — classic GoF: return after handling OR pass to next. Modern variants: return bool (handled?), return std::optional<Result>, or always-forward (every handler runs, used for middleware pipelines).


3. C++ Example

3.1 Classic OO form

#include <memory>
#include <string>
#include <iostream>

struct Request {
    std::string type;
    int amount;
};

class Handler {
public:
    virtual ~Handler() = default;
    void set_next(std::shared_ptr<Handler> n) { next_ = std::move(n); }
    virtual void handle(const Request& r) {
        if (next_) next_->handle(r);
    }
protected:
    std::shared_ptr<Handler> next_;
};

class TeamLead : public Handler {
public:
    void handle(const Request& r) override {
        if (r.amount <= 1'000) std::cout << "TeamLead approves " << r.amount << "\n";
        else Handler::handle(r);
    }
};

class Manager : public Handler {
public:
    void handle(const Request& r) override {
        if (r.amount <= 10'000) std::cout << "Manager approves " << r.amount << "\n";
        else Handler::handle(r);
    }
};

class Director : public Handler {
public:
    void handle(const Request& r) override {
        if (r.amount <= 100'000) std::cout << "Director approves " << r.amount << "\n";
        else std::cout << "Rejected: " << r.amount << " too large\n";
    }
};

int main() {
    auto lead = std::make_shared<TeamLead>();
    auto mgr  = std::make_shared<Manager>();
    auto dir  = std::make_shared<Director>();
    lead->set_next(mgr);
    mgr->set_next(dir);

    lead->handle({"expense", 500});
    lead->handle({"expense", 5'000});
    lead->handle({"expense", 50'000});
    lead->handle({"expense", 500'000});
}

3.2 Modern form — std::vector<std::function<bool(Req&)>>

For pipelines where each link is stateless, the OO scaffolding is overkill. Use a vector of callables; first one to return true consumes the request.

#include <functional>
#include <vector>
#include <string>
#include <iostream>

struct Req { std::string type; int amount; };

class Chain {
    std::vector<std::function<bool(Req&)>> handlers_;
public:
    Chain& add(std::function<bool(Req&)> h) {
        handlers_.push_back(std::move(h));
        return *this;
    }
    bool dispatch(Req r) {
        for (auto& h : handlers_) if (h(r)) return true;
        return false;
    }
};

int main() {
    Chain c;
    c.add([](Req& r) { if (r.amount <= 1'000)   { std::cout << "lead\n";   return true; } return false; })
     .add([](Req& r) { if (r.amount <= 10'000)  { std::cout << "mgr\n";    return true; } return false; })
     .add([](Req& r) { if (r.amount <= 100'000) { std::cout << "dir\n";    return true; } return false; });

    c.dispatch({"x", 5'000});   // mgr
    c.dispatch({"x", 5'000'000}); // unhandled → false
}

3.3 Always-forward middleware pipeline

Each link processes + forwards (no early exit). Mirrors Express / ASP.NET middleware.

using Next = std::function<void()>;
using Middleware = std::function<void(Req&, Next)>;

class Pipeline {
    std::vector<Middleware> mws_;
public:
    Pipeline& use(Middleware m) { mws_.push_back(std::move(m)); return *this; }
    void run(Req r) {
        std::function<void(size_t)> step = [&](size_t i) {
            if (i >= mws_.size()) return;
            mws_[i](r, [&] { step(i + 1); });
        };
        step(0);
    }
};

4. When to Use / When Not To

Use when:

  • More than one object may handle a request, decided at runtime.
  • Set of handlers should be configurable/orderable without recompile.
  • You want to add/remove handlers dynamically.
  • Sender shouldn't know which handler — only the entry point.

Don't use when:

  • Exactly one handler always handles → direct call.
  • Order of handlers doesn't matter and all must run → just iterate.
  • Performance-critical hot path — virtual dispatch + pointer chasing per link.
  • A simple switch / table lookup is clearer (rule of thumb: ≤3 cases, no future growth).

5. Variants and Pitfalls

Variants:

  • Linked list chain (classic GoF) vs vector / pipeline (data-oriented).
  • Tree CoR — Composite + CoR; request bubbles up parent pointers (GUI, DOM events).
  • Stop on first hit vs always run all (middleware).
  • Return valuevoid (classic), bool (handled?), std::optional<T>, or full Result<T, E>.

Pitfalls:

  • Request falls off the end unhandled. Always have a terminal default handler or check the return value.
  • Cycles. A → B → A infinite loop. Validate chain on construction.
  • Lifetime. Raw Handler* next-pointers + shared ownership of handlers → use cases dangling. shared_ptr / arena allocation safer.
  • Hidden coupling via shared state. Handlers sharing a mutable context object reintroduce the coupling CoR was supposed to remove.
  • Order matters but isn't obvious. Document chain assembly; consider builder pattern with named insertion points (before("auth")).

6. Related Patterns

  • Chain of Responsibility vs Decorator — both wrap + forward. CoR may not handle (early exit / no match → unhandled); Decorator always processes (adds behavior, then delegates). Structurally identical; intent differs.
  • CoR vs Strategy — Strategy picks one algorithm up front; CoR tries handlers in order until one accepts. CoR = runtime selection by data; Strategy = config-time selection.
  • CoR vs Command — Command encapsulates a request as an object; CoR routes it through handlers. Combined: enqueue a Command object, dispatch through a CoR chain.
  • CoR vs Composite — Tree-CoR uses Composite to give each node a parent reference for bubbling.
  • CoR vs Observer — Observer fans out one event to many independent listeners (all run, unordered). CoR is ordered, single-consumer.

7. References