Skip to content
C++
Idiom
since C++11
Basic

Chain of Responsibility

Chain of Responsibility in C++ — linked handler chains, functional pipelines, middleware, and event bubbling with concrete patterns.

Chain of Responsibilitysince C++11

A behavioral pattern that passes a request along a linked sequence of handlers, each deciding independently to process it, forward it, or abort — decoupling the sender from any specific receiver.

Overview

The central contract: a request has a well-defined type, any number of objects might handle it, and the sender needn't know which one will. The sender submits to the head of a chain; each link inspects the request and either handles it (consuming it or continuing) or forwards to the next link.

Three shapes appear in modern C++ codebases:

OOP chain — virtual base class with a next_ pointer; each concrete subclass overrides handle(). Straightforward ownership model; harder to compose at runtime. Available since C++98, improved significantly with std::unique_ptr in C++11.

Functional pipeline — a std::vector<std::function<bool(Request&)>> (C++11) iterated in order. Easier to build at runtime, no virtual dispatch overhead, lambdas compose naturally. The idiomatic modern default.

Bidirectional middleware — each handler receives a next thunk and can run code both before and after the downstream chain. Essential when response post-processing is required (logging status codes, compressing bodies, setting response headers).

Examples

OOP Handler Chain

The append method walks to the tail recursively, avoiding the "return a reference to a moved-from smart pointer" trap that a single-step set_next creates.

cpp
#include <memory>
#include <string>
#include <print>  // C++23

struct Request {
    std::string type;
    std::string payload;
    bool handled = false;
};

class Handler {
protected:
    std::unique_ptr<Handler> next_;  // C++11: unique_ptr owns the chain

public:
    virtual ~Handler() = default;

    Handler& append(std::unique_ptr<Handler> h) {
        if (next_) return next_->append(std::move(h));  // walk to tail
        next_ = std::move(h);
        return *next_;
    }

    virtual void handle(Request& req) {
        if (next_) next_->handle(req);
    }
};

class AuthHandler : public Handler {
public:
    void handle(Request& req) override {
        if (req.type == "AUTH") {
            std::println("auth: accepted {}", req.payload);  // C++23
            req.handled = true;
        } else {
            Handler::handle(req);
        }
    }
};

class RateLimitHandler : public Handler {
    int limit_;
    int count_ = 0;
public:
    explicit RateLimitHandler(int limit) : limit_{limit} {}

    void handle(Request& req) override {
        if (++count_ > limit_) {
            std::println("rate-limit: rejected {}", req.payload);  // C++23
            req.handled = true;
            return;
        }
        Handler::handle(req);
    }
};

class FallbackHandler : public Handler {
public:
    void handle(Request& req) override {
        if (!req.handled)
            std::println("fallback: unhandled type={}", req.type);  // C++23
    }
};

// log → rate-limit → auth → fallback; all owned transitively by `chain`
auto chain = std::make_unique<RateLimitHandler>(100);
chain->append(std::make_unique<AuthHandler>())
      .append(std::make_unique<FallbackHandler>());

Request r{"AUTH", "user=alice"};
chain->handle(r);

Functional Pipeline (C++11)

std::function<bool(Request&)> in a std::vector makes the chain first-class data — sortable, filterable, composable at runtime with no inheritance.

cpp
#include <functional>  // C++11
#include <vector>
#include <string>
#include <print>  // C++23

struct Request {
    std::string type;
    std::string payload;
};

class Pipeline {
    using Handler = std::function<bool(Request&)>;  // C++11; true = consumed
    std::vector<Handler> handlers_;

public:
    Pipeline& add(Handler h) {
        handlers_.push_back(std::move(h));
        return *this;
    }

    bool process(Request& req) const {
        for (const auto& h : handlers_) {  // C++11 range-for
            if (h(req)) return true;
        }
        return false;
    }
};

Pipeline p;
p.add([](Request& r) {
    std::println("log: {} — {}", r.type, r.payload);  // C++23; never consumes
    return false;
})
.add([](Request& r) {
    if (r.type != "RATE_LIMIT") return false;
    std::println("rate-limiter: rejecting {}", r.payload);  // C++23
    return true;
})
.add([](Request& r) {
    std::println("handler: processing {}", r.type);  // C++23
    return true;
});

Request req{"RATE_LIMIT", "client=10.0.0.1"};
p.process(req);

Bidirectional Middleware (C++14)

Middleware wraps next() to run code on both inbound requests and outbound responses. The lambda captures idx by reference safely: next lives on the stack within one dispatch call and is never stored.

cpp
#include <functional>
#include <vector>
#include <unordered_map>
#include <string>
#include <print>  // C++23

struct HttpRequest {
    std::string method;
    std::string path;
    std::unordered_map<std::string, std::string> headers;
};

struct HttpResponse {
    int status = 200;
    std::string body;
};

using Middleware = std::function<
    HttpResponse(HttpRequest&, std::function<HttpResponse()>)>;

class Router {
    std::vector<Middleware> mw_;
    std::function<HttpResponse(HttpRequest&)> route_;

public:
    Router& use(Middleware m) { mw_.push_back(std::move(m)); return *this; }
    Router& route(std::function<HttpResponse(HttpRequest&)> h) {
        route_ = std::move(h); return *this;
    }

    HttpResponse dispatch(HttpRequest req) {
        std::size_t idx = 0;
        std::function<HttpResponse()> next;  // C++11
        next = [&]() -> HttpResponse {
            if (idx < mw_.size()) return mw_[idx++](req, next);
            return route_ ? route_(req) : HttpResponse{404, "Not Found"};
        };
        return next();
    }
};

Router r;
r.use([](HttpRequest& req, auto next) {               // C++14: auto in lambda params
    std::println("→ {} {}", req.method, req.path);    // C++23
    auto resp = next();
    std::println("← {}", resp.status);               // C++23; runs after downstream
    return resp;
})
.use([](HttpRequest& req, auto next) {                // C++14
    if (!req.headers.count("Authorization"))
        return HttpResponse{401, "Unauthorized"};
    return next();
})
.route([](HttpRequest& req) {
    return HttpResponse{200, "OK"};
});

r.dispatch({"GET", "/api/data", {{"Authorization", "Bearer xyz"}}});

Event Bubbling in a Widget Tree (C++11)

The tree is owned top-down (unique_ptr); each node holds a raw non-owning pointer back to its parent to avoid ownership cycles. Events bubble child→parent — the opposite direction from ownership.

cpp
#include <memory>
#include <vector>
#include <functional>  // C++11

struct Event {
    enum class Type { KeyPress, MouseClick } type;  // C++11 scoped enum
    int x = 0, y = 0;
    bool consumed = false;
};

class Widget {
    Widget* parent_ = nullptr;  // non-owning; C++11 nullptr

public:
    virtual ~Widget() = default;
    void set_parent(Widget* p) { parent_ = p; }

    virtual bool on_event(Event& e) {
        if (parent_ && !e.consumed)
            return parent_->on_event(e);  // bubble up the tree
        return false;
    }
};

class Button : public Widget {
    std::function<void()> on_click_;  // C++11
public:
    explicit Button(std::function<void()> cb) : on_click_{std::move(cb)} {}

    bool on_event(Event& e) override {
        if (e.type == Event::Type::MouseClick) {
            on_click_();
            e.consumed = true;
            return true;
        }
        return Widget::on_event(e);
    }
};

class Panel : public Widget {
    std::vector<std::unique_ptr<Widget>> children_;  // C++11; owns children

public:
    Widget& add(std::unique_ptr<Widget> child) {
        child->set_parent(this);
        return *children_.emplace_back(std::move(child));
    }
};

Best Practices

  • Prefer the functional pipeline over a virtual-dispatch chain for new code. Runtime composition is easier, handlers are testable as isolated callables, and the code avoids deep inheritance.
  • Make handler semantics explicit. Return bool (consumed/not) for filter chains, std::optional<Response> when the handled result carries data, void for always-pass-through chains like logging.
  • Separate observation from consumption. Logging, metrics, and tracing handlers should always forward (return false). Mixing them with consuming handlers creates ordering bugs that are hard to trace.
  • Cap chain length. A chain of 20+ handlers signals a missing abstraction. Group thematically related handlers into a composite handler with a clear name.
  • Use std::move when registering handlers. Stateful lambdas and closures are not cheaply copyable; silent copies duplicate state and can cause subtle bugs.

Common Pitfalls

Dangling reference from set_next. A single-step set_next(std::unique_ptr<Handler> h) that returns *next_ after std::move(h) is only safe if the caller does not chain another .set_next through the returned reference on the same object. The tail-walking append shown above avoids this entirely.

Unconditional forwarding loops. A handler that calls Handler::handle(req) unconditionally will propagate forever if the terminal handler also unconditionally forwards. Every chain needs a definite terminator.

std::function overhead in hot paths. std::function (C++11) uses type erasure and may heap-allocate the stored callable. For high-throughput paths (packet processing, audio callbacks), consider a std::variant-based dispatch table or a compile-time handler list with templates; since C++20, concepts can constrain handler types cleanly.

Reaching for middleware when a filter chain is enough. Bidirectional middleware (next() thunk) is heavier than a simple boolean-return pipeline. Only use it when post-processing genuinely requires the downstream response; otherwise the simpler form is clearer and measurably faster.

Functional chain with std::function

std::function turns handler registration into a one-liner. Each slot in the chain is a heterogeneous callable — lambda, function pointer, or a bound member — unified under a single type. The chain itself becomes ordinary data that can be stored in a container, serialized to a config, or reordered at runtime.

cpp
// C++11
#include <functional>
#include <vector>
#include <optional>
#include <string>

struct Ticket {
    int priority;        // 1 = low, 3 = high
    std::string issue;
};

using TicketHandler = std::function<std::optional<std::string>(const Ticket&)>;

class SupportChain {
    std::vector<TicketHandler> handlers_;
public:
    SupportChain& add(TicketHandler h) {
        handlers_.push_back(std::move(h));  // C++11: move, don't copy
        return *this;
    }

    std::string dispatch(const Ticket& t) const {
        for (const auto& h : handlers_) {
            if (auto result = h(t); result.has_value())  // C++17: init-if
                return *result;
        }
        return "unresolved";
    }
};

// Build chain entirely from lambdas — no class hierarchy needed
SupportChain chain;
chain
  .add([](const Ticket& t) -> std::optional<std::string> {
      if (t.priority == 3) return "escalated to senior engineer";
      return std::nullopt;  // C++17
  })
  .add([](const Ticket& t) -> std::optional<std::string> {
      if (t.issue.starts_with("billing"))  // C++20: starts_with
          return "forwarded to billing team";
      return std::nullopt;
  })
  .add([](const Ticket&) -> std::optional<std::string> {
      return "handled by tier-1 support";  // always fires; acts as fallback
  });

Ticket t{2, "billing inquiry about invoice"};
auto answer = chain.dispatch(t);  // "forwarded to billing team"

std::optional<T> as the return type makes "I handled it, here is the result" semantically distinct from "I did not handle it, keep going". Returning std::nullopt is cheaper than exceptions for the common non-handling case.

Chain as middleware pipeline

Middleware composes cross-cutting concerns without touching business logic. Each layer wraps the rest of the chain behind a next callable, enabling pre- and post-processing in a single function body — the same model used by Express.js, ASP.NET Core, and virtually every HTTP framework.

cpp
// C++14 — auto parameters in generic lambdas
#include <functional>
#include <chrono>
#include <string>
#include <print>  // C++23

struct Ctx {
    std::string path;
    std::string body;
    int status = 200;
};

using Next     = std::function<void()>;
using Layer    = std::function<void(Ctx&, Next)>;

class App {
    std::vector<Layer> layers_;
public:
    App& use(Layer l) { layers_.push_back(std::move(l)); return *this; }

    void run(Ctx ctx) const {
        std::size_t i = 0;
        std::function<void()> dispatch;
        dispatch = [&] {
            if (i < layers_.size()) layers_[i++](ctx, dispatch);
        };
        dispatch();
    }
};

App app;

// Timing middleware — measures total downstream latency
app.use([](Ctx& ctx, auto next) {  // C++14: auto next
    auto t0 = std::chrono::steady_clock::now();
    next();
    auto dt = std::chrono::steady_clock::now() - t0;
    std::println("timing: {}µs for {}", 
        std::chrono::duration_cast<std::chrono::microseconds>(dt).count(),
        ctx.path);  // C++23
});

// Auth guard middleware
app.use([](Ctx& ctx, auto next) {  // C++14
    if (ctx.path.starts_with("/admin")) {  // C++20
        ctx.status = 403;
        ctx.body   = "Forbidden";
        return;  // short-circuit: downstream never runs
    }
    next();
});

// Business logic — the actual "route"
app.use([](Ctx& ctx, auto) {
    ctx.body = "Hello from " + ctx.path;
});

app.run({"/api/hello", ""});

The i variable captured by reference is safe as long as dispatch does not outlive run. Never store the next callable past the current invocation — doing so creates dangling captures.

Type-safe chain with std::variant

std::variant enables a zero-overhead, type-safe alternative to virtual dispatch. When the full set of request types is known at compile time, std::visit replaces vtable lookup with a jump table, and the compiler enforces exhaustive handling.

cpp
// C++17
#include <variant>
#include <string>
#include <print>  // C++23

struct LoginRequest  { std::string username; std::string password; };
struct LogoutRequest { std::string session_id; };
struct DataRequest   { std::string resource; int page; };

using AnyRequest = std::variant<LoginRequest, LogoutRequest, DataRequest>;  // C++17

// Each handler is an overload set; std::visit dispatches to the matching arm
struct AuthHandler {
    bool operator()(LoginRequest& r) const {
        std::println("auth: login attempt for {}", r.username);  // C++23
        return r.password == "hunter2";  // simplified
    }
    bool operator()(LogoutRequest& r) const {
        std::println("auth: logout session {}", r.session_id);  // C++23
        return true;
    }
    bool operator()(DataRequest&) const { return false; }  // pass through
};

struct DataHandler {
    bool operator()(DataRequest& r) const {
        std::println("data: serving {} page {}", r.resource, r.page);  // C++23
        return true;
    }
    bool operator()(auto&) const { return false; }  // C++20: generic catch-all
};

// Combine overload sets into one visitor — C++17 overloaded trick
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;  // C++17 CTAD

void dispatch(AnyRequest req) {
    bool handled = std::visit(overloaded{AuthHandler{}, DataHandler{}}, req);
    if (!handled) std::println("warn: unhandled request");  // C++23
}

dispatch(LoginRequest{"alice", "hunter2"});
dispatch(DataRequest{"articles", 2});

The compiler rejects incomplete operator() sets, turning missed request types into link-time errors instead of silent runtime pass-throughs. This is the primary advantage over the std::function approach for closed request hierarchies.

Bidirectional chains

A bidirectional chain processes a request on the way in and the response on the way out. This is structurally different from a one-way pipeline: each node has two responsibilities, and the ordering of nodes matters for both traversal directions independently.

cpp
// C++11
#include <functional>
#include <vector>
#include <string>
#include <print>  // C++23

struct Req  { std::string body; };
struct Resp { int code; std::string body; };

class BidirChain {
public:
    using Layer = std::function<Resp(Req&, std::function<Resp(Req&)>)>;

private:
    std::vector<Layer> layers_;
    std::function<Resp(Req&)> core_;  // innermost handler

public:
    BidirChain& wrap(Layer l)              { layers_.push_back(std::move(l)); return *this; }
    BidirChain& core(std::function<Resp(Req&)> h) { core_ = std::move(h); return *this; }

    Resp execute(Req req) const {
        // Build the composed function right-to-left so the first `wrap` call
        // becomes the outermost layer — execution order matches insertion order.
        std::function<Resp(Req&)> inner = core_;
        for (auto it = layers_.rbegin(); it != layers_.rend(); ++it) {
            auto prev = inner;
            auto& layer = *it;
            inner = [layer, prev](Req& r) { return layer(r, prev); };  // C++11
        }
        return inner(req);
    }
};

BidirChain chain;
chain
  .wrap([](Req& r, auto next) -> Resp {  // C++14
      std::println("compress: request in ({}B)", r.body.size());  // C++23
      Resp resp = next(r);
      std::println("compress: response out ({} {})", resp.code, resp.body);
      return resp;
  })
  .wrap([](Req& r, auto next) -> Resp {  // C++14
      std::println("cache: checking cache");  // C++23
      // In a real impl: return cached value if present
      Resp resp = next(r);
      std::println("cache: storing response");
      return resp;
  })
  .core([](Req& r) -> Resp {
      return {200, "processed: " + r.body};
  });

auto result = chain.execute({"hello world"});

Outer layers execute first on the inbound path and last on the outbound path. Insertion order determines the wrapping order, making it easy to reason about which layer sees the final response first (the innermost wrap, closest to core).

Chain vs strategy vs decorator

These three patterns look alike but solve different problems. Getting the choice wrong leads to accidental complexity — a chain where only one handler ever fires should probably be a strategy; a decorator that conditionally skips its wrapping logic is secretly a chain.

DimensionChain of ResponsibilityStrategyDecorator
Handler countMultiple; any may fireExactly one firesExactly one wraps another
SelectionHandler decides at runtimeCaller selects at configuration timeTransparent to caller
PurposeRoute / filter requestsVary an algorithmAdd behaviour to an object
CouplingHandlers unaware of each otherContext knows current strategyDecorator and component share interface
C++ idiomvector<function<bool(Req&)>>function<Result(Args...)> or policyInherits same interface, forwards calls
cpp
// C++11 — side-by-side micro-examples

// --- Chain: multiple handlers inspect; first match wins ---
std::vector<std::function<bool(int)>> chain {
    [](int n) { if (n < 0)   { /*handle negative*/ return true; } return false; },
    [](int n) { if (n == 0)  { /*handle zero*/     return true; } return false; },
    [](int n) { if (n > 100) { /*handle large*/    return true; } return false; },
};

// --- Strategy: exactly one algorithm selected before dispatch ---
std::function<int(int,int)> strategy = [](int a, int b) { return a + b; };
// swap at runtime: strategy = [](int a, int b) { return a * b; };

// --- Decorator: wraps and delegates ---
struct Sorter { virtual void sort(std::vector<int>&) = 0; virtual ~Sorter() = default; };
struct TimedSorter : Sorter {
    std::unique_ptr<Sorter> inner_;  // C++11
    explicit TimedSorter(std::unique_ptr<Sorter> s) : inner_{std::move(s)} {}
    void sort(std::vector<int>& v) override {
        // measure time, then delegate — always delegates, never skips
        inner_->sort(v);
    }
};

Rule of thumb: if you find yourself writing if (should_skip) return next() inside what you called a decorator, you have written a chain link instead.

Async chain with futures

An async chain passes std::future (or a promise/continuation) through each handler instead of a synchronous return value. This enables handlers to perform I/O, schedule work on thread pools, or defer processing without blocking the calling thread. Available since C++11; std::async and std::promise are the standard-library building blocks.

cpp
// C++17
#include <future>
#include <functional>
#include <vector>
#include <string>
#include <print>  // C++23

struct AsyncReq  { std::string resource; };
struct AsyncResp { int code; std::string body; };

// Each async handler returns a future; if it doesn't handle the request it
// returns an invalid (default-constructed) future as the "pass" signal.
using AsyncHandler = std::function<std::future<AsyncResp>(AsyncReq)>;

class AsyncChain {
    std::vector<AsyncHandler> handlers_;
public:
    AsyncChain& add(AsyncHandler h) {
        handlers_.push_back(std::move(h));
        return *this;
    }

    std::future<AsyncResp> dispatch(AsyncReq req) const {
        for (const auto& h : handlers_) {
            auto fut = h(req);
            if (fut.valid()) return fut;  // C++11: valid() == has shared state
        }
        // No handler claimed the request
        std::promise<AsyncResp> p;
        p.set_value({404, "not found"});
        return p.get_future();
    }
};

AsyncChain chain;

// Simulate an async cache lookup
chain.add([](AsyncReq req) -> std::future<AsyncResp> {
    if (req.resource == "/cached") {
        return std::async(std::launch::async, [req]() -> AsyncResp {  // C++11
            std::println("cache: hit for {}", req.resource);  // C++23
            return {200, "cached body"};
        });
    }
    return {};  // default-constructed future: invalid, means "pass"
});

// Simulate an async origin fetch
chain.add([](AsyncReq req) -> std::future<AsyncResp> {
    return std::async(std::launch::async, [req]() -> AsyncResp {  // C++11
        std::println("origin: fetching {}", req.resource);  // C++23
        return {200, "fresh body from " + req.resource};
    });
});

auto fut = chain.dispatch({"/"});
auto resp = fut.get();  // block until resolved
std::println("result: {} {}", resp.code, resp.body);  // C++23

std::async with std::launch::async guarantees a new thread per handler invocation; for production use, replace it with a thread-pool executor or coroutines (co_await, C++20) to avoid thread proliferation. The pattern shown above is correct for C++17 and is a direct stepping-stone toward a coroutine-based pipeline.

See Also