Skip to content
Commitwise
Menu
Design Patterns

Chain of Responsibility

Pass a request along a chain of handlers until one handles it, such as deep links routed to the right screen, then use the same idea as middleware and interceptors that wrap a call, get the order right, always call or deliberately skip the next handler, in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The app opens links from emails, push notifications and the web: /orders/123, /products/42, /products/sale, /promo/SPRING, /support. The first version had one function with a switch on the first path segment and nested ifs for the special cases. Every team that added a screen edited that function, two teams broke each other’s links in the same release, and nobody could test promo links without the order screen’s dependencies.

Chain of Responsibility splits that function into small handlers. The link is passed along the chain; each handler either handles it or passes it to the next one. Adding a screen means adding a handler, not editing everybody else’s.

New to some of these? Open what you need, or skip ahead.

Basics:What is a deep link?

A deep link is a URL that opens a specific screen inside an app instead of its start screen: tapping https://shop.example.com/orders/123 in an email opens order 123. The Deep link glossary entry has more.

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java, TypeScript) or protocol (Swift) is a list of things a type promises it can do, without saying how. Every handler here implements the same small interface, so the chain can treat them all alike.

Part Not started

Pass it along until someone handles it

Calling a company’s support line works like this. The first person you reach answers simple questions. If yours is about billing, they pass you to billing. If billing can’t help, they pass you to a specialist. You only ever dialed one number; you don’t need to know who’s behind it or in what order.

In code, each person is a handler with one question to answer: “can I handle this?” If yes, it does the work and the request stops there. If no, it passes the request to the next handler. The code that starts the request only knows the first handler.

Chain of Responsibility decouples the sender of a request from its receivers by giving several objects a chance to handle it, passing it along a chain until one does. In its classic form exactly one handler handles the request and the rest never see it. Its strengths: handlers are independent, testable in isolation, and the chain is assembled (and reordered) at the composition root. Its costs: no compile-time guarantee that anything handles the request (you need a terminal fallback), and behavior depends on order, which is now configuration. A list of handlers with “first that returns a result wins” is the same pattern without linked next pointers, and is usually simpler.

enum Route { case order(String), product(String), sale, promo(String), home }

protocol DeepLinkHandler {
    func route(for url: URL) -> Route?       // nil means "not mine, pass it on"
}

struct OrderLinkHandler: DeepLinkHandler {
    func route(for url: URL) -> Route? {
        let parts = url.pathComponents       // ["/", "orders", "123"]
        guard parts.count == 3, parts[1] == "orders" else { return nil }
        return .order(parts[2])
    }
}

struct SaleLinkHandler: DeepLinkHandler {
    func route(for url: URL) -> Route? { url.path == "/products/sale" ? .sale : nil }
}

struct ProductLinkHandler: DeepLinkHandler {
    func route(for url: URL) -> Route? {
        let parts = url.pathComponents
        guard parts.count == 3, parts[1] == "products" else { return nil }
        return .product(parts[2])
    }
}

struct DeepLinkRouter {
    let handlers: [DeepLinkHandler]
    func route(for url: URL) -> Route {
        handlers.lazy.compactMap { $0.route(for: url) }.first ?? .home   // first match wins
    }
}

let router = DeepLinkRouter(handlers: [OrderLinkHandler(), SaleLinkHandler(), ProductLinkHandler(), PromoLinkHandler()])
sealed interface Route {
    data class Order(val id: String) : Route
    data class Product(val id: String) : Route
    data object Sale : Route
    data class Promo(val code: String) : Route
    data object Home : Route
}

fun interface DeepLinkHandler {
    fun route(uri: Uri): Route?               // null means "not mine, pass it on"
}

val orderLinks = DeepLinkHandler { uri ->
    val s = uri.pathSegments                   // ["orders", "123"]
    if (s.size == 2 && s[0] == "orders") Route.Order(s[1]) else null
}

val saleLinks = DeepLinkHandler { uri -> if (uri.path == "/products/sale") Route.Sale else null }

val productLinks = DeepLinkHandler { uri ->
    val s = uri.pathSegments
    if (s.size == 2 && s[0] == "products") Route.Product(s[1]) else null
}

class DeepLinkRouter(private val handlers: List<DeepLinkHandler>) {
    fun route(uri: Uri): Route =
        handlers.firstNotNullOfOrNull { it.route(uri) } ?: Route.Home   // first match wins
}

val router = DeepLinkRouter(listOf(orderLinks, saleLinks, productLinks, promoLinks))
public sealed interface Route {
    record Order(String id) implements Route {}
    record Product(String id) implements Route {}
    record Sale() implements Route {}
    record Promo(String code) implements Route {}
    record Home() implements Route {}
}

@FunctionalInterface
public interface DeepLinkHandler {
    Optional<Route> route(URI uri);           // empty means "not mine, pass it on"
}

DeepLinkHandler orderLinks = uri -> {
    String[] s = uri.getPath().split("/");    // ["", "orders", "123"]
    return s.length == 3 && s[1].equals("orders") ? Optional.of(new Route.Order(s[2])) : Optional.empty();
};

DeepLinkHandler saleLinks = uri ->
    uri.getPath().equals("/products/sale") ? Optional.of(new Route.Sale()) : Optional.empty();

DeepLinkHandler productLinks = uri -> {
    String[] s = uri.getPath().split("/");
    return s.length == 3 && s[1].equals("products") ? Optional.of(new Route.Product(s[2])) : Optional.empty();
};

public record DeepLinkRouter(List<DeepLinkHandler> handlers) {
    public Route route(URI uri) {
        return handlers.stream()
            .map(h -> h.route(uri))
            .flatMap(Optional::stream)
            .findFirst()                       // first match wins (streams are lazy)
            .orElse(new Route.Home());
    }
}
type Route =
  | { kind: "order"; id: string }
  | { kind: "product"; id: string }
  | { kind: "sale" }
  | { kind: "promo"; code: string }
  | { kind: "home" };

type DeepLinkHandler = (url: URL) => Route | null;   // null means "not mine, pass it on"

const orderLinks: DeepLinkHandler = (url) => {
  const s = url.pathname.split("/");                  // ["", "orders", "123"]
  return s.length === 3 && s[1] === "orders" ? { kind: "order", id: s[2] } : null;
};

const saleLinks: DeepLinkHandler = (url) =>
  url.pathname === "/products/sale" ? { kind: "sale" } : null;

const productLinks: DeepLinkHandler = (url) => {
  const s = url.pathname.split("/");
  return s.length === 3 && s[1] === "products" ? { kind: "product", id: s[2] } : null;
};

function createRouter(handlers: DeepLinkHandler[]) {
  return (url: URL): Route => {
    for (const handle of handlers) {
      const route = handle(url);
      if (route) return route;                        // first match wins
    }
    return { kind: "home" };
  };
}

const route = createRouter([orderLinks, saleLinks, productLinks, promoLinks]);
  • Each handler knows one kind of link and returns a route, or “nothing” to say “not mine”.
  • The router asks the handlers in order and stops at the first one that answers. If none does, the app goes to the home screen, so an unknown link never crashes it.
  • Adding a screen means writing one handler and adding it to the list. Nobody else’s code changes, and each handler can be tested with a few URLs.
  • Order matters: /products/sale also looks like a product link with id sale. The specific sale handler must come before the general product handler.
Spot the bugThe sale banner opens an empty product page

Tap the line with the bug.

let router = DeepLinkRouter(handlers: [
    OrderLinkHandler(),
    ProductLinkHandler(),
    SaleLinkHandler(),
    PromoLinkHandler(),
])
val router = DeepLinkRouter(
    listOf(
        orderLinks,
        productLinks,
        saleLinks,
        promoLinks,
    )
)
var router = new DeepLinkRouter(List.of(
    orderLinks,
    productLinks,
    saleLinks,
    promoLinks
));
const route = createRouter([
  orderLinks,
  productLinks,
  saleLinks,
  promoLinks,
]);

Predict the output

With the fixed order (orders, sale, products, promo), what does the router return for /support?

  • Sender knows only the chain; handlers decide to handle or pass. Classic form: exactly one handler handles.
  • Order is configuration: specific before general; always a terminal fallback (home, 404, error).
  • A list of handlers with “first non-null wins” is the same pattern without next pointers, and usually simpler.
  • Test the assembled chain against every known input, not just each handler alone.

Check this part

  1. What does a handler do with a request it can't handle?

  2. Why must the sale handler come before the product handler?

  3. What guarantees that every request is handled?

Each handler gets the request and either handles it or passes it on. The sender only knows the start of the chain. The first handler that can handle the request wins, so order matters: specific handlers before general ones, with a fallback at the end.

Part Not started

Middleware: every handler wraps the rest

There’s a second, very common form of the chain. Instead of “one handler takes the request”, every handler gets a turn, does something, and then calls the next one. When the next one returns, it can do something with the response too. This is called middleware (on servers) or interceptors (in HTTP clients like OkHttp).

A request from the app might go through:

  1. a logging step that notes the time,
  2. an auth step that adds the access token,
  3. a retry step that tries again if the server is briefly unavailable,
  4. and finally the real network call.

Each step calls next, so the request travels down the chain and the response travels back up through every step.

The pipeline variant (servlet filters, Spring’s HandlerInterceptor and filter chain, OkHttp interceptors, Ktor plugins, Express and Koa middleware, URLSession wrappers) gives every handler (request, next). Each can modify the request, short-circuit with its own response (cached, unauthorized, rate-limited), call next and post-process the response, or call next again (retries). It’s Chain of Responsibility combined with the Decorator idea: each link wraps the rest of the chain. The contract is strict: call next exactly once unless you deliberately short-circuit or retry, and propagate its result. Order is behavior: logging outside retry logs one logical call, inside logs every attempt; auth inside retry lets each attempt get a fresh token; caching before auth risks serving one user’s cached response to another.

typealias Next = (URLRequest) async throws -> (Data, HTTPURLResponse)

protocol Interceptor {
    func intercept(_ request: URLRequest, next: Next) async throws -> (Data, HTTPURLResponse)
}

struct AuthInterceptor: Interceptor {
    let tokens: TokenStore
    func intercept(_ request: URLRequest, next: Next) async throws -> (Data, HTTPURLResponse) {
        var request = request
        request.setValue("Bearer " + (try await tokens.current()), forHTTPHeaderField: "Authorization")
        return try await next(request)                    // call the rest of the chain
    }
}

struct RetryInterceptor: Interceptor {
    let attempts = 3
    func intercept(_ request: URLRequest, next: Next) async throws -> (Data, HTTPURLResponse) {
        for attempt in 1..<attempts {
            let result = try await next(request)
            if result.1.statusCode != 503 { return result }
            try await Task.sleep(for: .milliseconds(200 * attempt))
        }
        return try await next(request)                     // last attempt: return whatever it gives
    }
}

// Builds next functions from the end: the last one performs the real request
func makeChain(_ interceptors: [Interceptor], send: @escaping Next) -> Next {
    interceptors.reversed().reduce(send) { next, interceptor in
        { request in try await interceptor.intercept(request, next: next) }
    }
}

let perform = makeChain([LoggingInterceptor(), RetryInterceptor(), AuthInterceptor(tokens: tokens)],
                        send: urlSessionSend)
// OkHttp: each interceptor gets the chain and calls chain.proceed(request) to continue
class AuthInterceptor(private val tokens: TokenStore) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request().newBuilder()
            .header("Authorization", "Bearer ${tokens.current()}")
            .build()
        return chain.proceed(request)                     // call the rest of the chain
    }
}

class RetryInterceptor(private val attempts: Int = 3) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        repeat(attempts - 1) { attempt ->
            val response = chain.proceed(chain.request())
            if (response.code != 503) return response
            response.close()                               // release the connection before retrying
            Thread.sleep(200L * (attempt + 1))
        }
        return chain.proceed(chain.request())
    }
}

val client = OkHttpClient.Builder()
    .addInterceptor(LoggingInterceptor())     // outermost: sees one logical call
    .addInterceptor(RetryInterceptor())
    .addInterceptor(AuthInterceptor(tokens))  // inside retry: every attempt gets a current token
    .build()
// Spring: a servlet filter chain on the server side
@Component
@Order(1)
public class RequestIdFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String requestId = Optional.ofNullable(request.getHeader("X-Request-Id"))
                .orElseGet(() -> UUID.randomUUID().toString());
        MDC.put("requestId", requestId);
        response.setHeader("X-Request-Id", requestId);
        try {
            chain.doFilter(request, response);             // call the rest of the chain
        } finally {
            MDC.remove("requestId");                       // runs after the response, even on errors
        }
    }
}

@Component
@Order(2)
public class MaintenanceFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        if (maintenance.isOn()) {
            response.sendError(503, "Back soon");          // short-circuit: next is not called
            return;
        }
        chain.doFilter(request, response);
    }
}
type Next = (req: Request) => Promise<Response>;
type Interceptor = (req: Request, next: Next) => Promise<Response>;

const auth = (tokens: TokenStore): Interceptor => async (req, next) => {
  const headers = new Headers(req.headers);
  headers.set("Authorization", `Bearer ${await tokens.current()}`);
  return next(new Request(req, { headers }));            // call the rest of the chain
};

const retry = (attempts = 3): Interceptor => async (req, next) => {
  for (let attempt = 1; attempt < attempts; attempt++) {
    const res = await next(req.clone());
    if (res.status !== 503) return res;
    await new Promise((r) => setTimeout(r, 200 * attempt));
  }
  return next(req.clone());
};

// Builds next functions from the end: the last one performs the real request
function chain(interceptors: Interceptor[], send: Next): Next {
  return interceptors.reduceRight<Next>((next, interceptor) => (req) => interceptor(req, next), send);
}

const perform = chain([logging, retry(), auth(tokens)], (req) => fetch(req));
  • next (or chain.proceed, chain.doFilter) means “run everything after me and give me the response”.
  • Code before next changes the request (adding a header); code after it can look at or change the response (logging the status, clearing the request id).
  • Not calling next is allowed, but only on purpose: the maintenance filter answers 503 itself, and the real handler never runs.
  • Retry calls next several times. That’s why auth is placed inside retry: each attempt goes through auth again and gets the current token.

Production puzzles

Predict the output

A caching interceptor is added before auth: it looks up responses by URL and returns a cached response without calling next. GET /me returns the current user's profile. What can go wrong on a shared device after a logout and login as someone else?

Predict the output

Kotlin, OkHttp: RetryInterceptor gets a 503, and calls chain.proceed() again without closing the first response. What happens?

Java note

In a servlet filter, code after chain.doFilter runs after the response has been produced, but the response may already be committed (headers sent). Setting headers there silently does nothing. Set response headers before calling doFilter, as RequestIdFilter does, or use a response wrapper.

Common mistakes in pipeline chains

  • Forgetting to return the result of next (or not awaiting it): the request runs, but the caller gets nothing, or an error escapes unhandled.
  • Calling next twice by accident, for example once in a try and again in the catch: the request is sent twice, and a POST creates two orders.
  • Swallowing errors: a catch that logs and returns a fake success hides failures from every handler further up.
  • Blocking: a synchronous sleep in a retry interceptor ties up a thread; fine on OkHttp’s background threads, not on a server’s request thread pool under load, where backoff belongs in an async client or a circuit breaker.
Exerciseeasy

Order the interceptors

An app’s HTTP client needs four interceptors: logging (one log line per call the app makes, with total time), auth (adds the current token), retry (up to 3 attempts on 503), and offline check (fails immediately with a friendly error when there’s no network). Put them in order from outermost to innermost, and say why.

›

For each one ask: should it run once per call, or once per attempt? Should it run at all when the app is offline?

SolutionTry it yourself first
  1. Logging, outermost, so it sees each call once, including the total time of all retries and the final result, even when offline.
  2. Offline check, so offline calls stop immediately without retrying.
  3. Retry, wrapping everything below it.
  4. Auth, innermost, so every attempt reads the current token. If the token was refreshed between attempts, the retry uses the new one.

If you also want a line per attempt, add a second, more detailed logger inside retry.

  • Pipeline variant: (request, next); every link may act before and after the rest (Decorator-like). Servlet filters, OkHttp interceptors, Ktor plugins, Express/Koa.
  • Contract: call next exactly once, or deliberately short-circuit or retry; return its result; don’t swallow errors.
  • Order is behavior and security: logging outside retry, auth inside retry, caches keyed by identity and never ahead of auth for private data.
  • OkHttp: close discarded responses before calling proceed again; servlet filters: headers after doFilter may be ignored once committed.

Check this part

  1. What does calling next in an interceptor do?

  2. Why put auth inside retry rather than outside?

  3. A middleware calls next in try and again in catch. Risk?

Middleware and interceptors are a chain where every handler can act before and after the rest of the chain by calling next. Each one must call next exactly once or deliberately short-circuit. Order decides behavior: logging outside retries sees one call, inside it sees each attempt.

Key takeaways

  • A chain passes a request from handler to handler until one handles it.
  • Put specific handlers before general ones, and end with a fallback.
  • Middleware and interceptors are chains where every step calls next.
  • Order decides what each step sees.
  • Decouples senders from receivers; order is configuration, so test the assembled chain.
  • Two variants: first-match routing and wrapping pipelines.
  • Call next exactly once, or short-circuit or retry on purpose.
  • Chain order affects correctness and security: retries, auth, caching.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.