Chain of Responsibility Design Pattern in Swift


Greetings, traveler!

We continue to learn about Design Patterns. We have started to explore Behavioral Patterns, and the first one we will be looking at is the Chain of Responsibility Pattern.

If you ever had a piece of logic that looks like a growing list of conditions, you have probably seen this pattern without naming it.

A request comes in. Several components can potentially handle it. Each one checks whether the request matches its responsibility. If yes, it handles it. If not, it forwards the request to the next handler.

That is Chain of Responsibility — it is all about getting rid of giant switch blocks and nested if trees that turn into a maintenance trap the moment requirements change.

The problem: conditional logic that keeps expanding

Most applications have some form of state-driven processing:

  • onboarding steps
  • access restrictions
  • password/security requirements
  • “blocked” flows
  • re-authentication
  • subscription gates

It usually starts simple. Then somebody adds one more case. Then another. Then an exception. Then a special rule for a single screen. Soon you end up with one function that knows too much and changes too often.

This is where Chain of Responsibility fits well.

The Chain of responsibility is a design pattern that allows requests to be processed sequentially by a series of handlers. Each handler decides whether or not to process the request based on its own criteria. If a handler chooses to process the request, it may pass it on to the next handler in the chain or decide to handle it. This allows for flexibility in handling requests and ensures that each handler can choose whether or not it wants to take on the responsibility of processing the request.

What chain of responsibility actually gives you

Before we jump into code, it is worth stating what the pattern does (and what it does not).

Chain of Responsibility is a routing mechanism.

  • It lets you split decision logic into small handlers
  • It makes the order explicit
  • It allows the chain to be configured dynamically (if needed)
  • It keeps each handler focused on one responsibility

It does not magically make logic better. If handlers are poorly defined, the chain will still be messy. But the structure makes it much harder to accidentally create a “god function”.

A minimal model: user state

Let’s use a simple example: user state.

enum UserState: Equatable {
    case onboarding
    case needsPasswordChange
    case blocked(reason: String)
    case authorized
}

The goal is to process a state and decide what to do next (navigate, show a modal, block access, etc.).

A handler protocol with a real result type

A common mistake in CoR examples is using Void return type. It works for demos, but it is weak design for production code because you cannot tell whether the request was processed.

enum UserStateAction: Equatable {
    case showOnboarding
    case showPasswordChange
    case showBlocked
    case proceed
}

Now we can define a handler:

protocol UserStateHandler {
    var next: UserStateHandler? { get }

    func handle(_ state: UserState) -> UserStateAction?
}

A handler either returns an action or forwards the state to the next handler.

Base handler implementation

We can reduce boilerplate by adding a base implementation:

extension UserStateHandler {
    func forward(_ state: UserState) -> UserStateAction? {
        next?.handle(state)
    }
}

This keeps every handler small.

Concrete handlers

Each handler has one responsibility.

Onboarding handler

struct OnboardingHandler: UserStateHandler {
    let next: UserStateHandler?

    func handle(_ state: UserState) -> UserStateAction? {
        guard state == .onboarding else { return forward(state) }
        return .showOnboarding
    }
}

Password change handler

struct PasswordChangeHandler: UserStateHandler {
    let next: UserStateHandler?
    
    func handle(_ state: UserState) -> UserStateAction? {
        guard state == .needsPasswordChange else { return forward(state) }
        return .showPasswordChange
    }
}

Blocked handler

struct BlockedHandler: UserStateHandler {
    let next: UserStateHandler?

    func handle(_ state: UserState) -> UserStateAction? {
        guard state == .blocked else { return forward(state) }
        return .showBlocked
    }
}

Default handler (terminal handler)

A chain without a default handler creates a “no result” scenario. Some teams are fine with that. Most apps should not be.

You can create a terminal handler that guarantees an action.

struct DefaultHandler: UserStateHandler {
    let next: UserStateHandler? = nil

    func handle(_ state: UserState) -> UserStateAction? {
        .proceed
    }
}

Building the chain

The order is not is part of the business logic.

With Swift value types, chain building is also clean and explicit:

func makeUserStateChain() -> UserStateHandler {
    let tail = DefaultHandler()
    let blocked = BlockedHandler(next: tail)
    let password = PasswordChangeHandler(next: blocked)
    let onboarding = OnboardingHandler(next: password)
    return onboarding
}

This reads like a pipeline.

Using the chain

let chain = makeUserStateChain()

let state: UserState = .blocked

guard let action = chain.handle(state) else { return }

switch action {
case .showOnboarding:
    print("Navigate to onboarding")
    
case .showPasswordChange:
    print("Navigate to password change")
    
case .showBlocked(let reason):
    print("Show blocked screen: \(reason)")
    
case .proceed:
    print("Continue into the app")
}

Each rule lives in one place.

Dynamic configuration

Sometimes you do not want the chain to be fixed. Example: features behind A/B test, remote config, or build flags.

The simplest approach is to build different chains.

func makeUserStateChain(isPasswordChangeEnabled: Bool) -> UserStateHandler {
    let tail = DefaultHandler()
    let blocked = BlockedHandler(next: tail)

    if isPasswordChangeEnabled {
        let password = PasswordChangeHandler(next: blocked)
        return OnboardingHandler(next: password)
    } else {
        return OnboardingHandler(next: blocked)
    }
}

This is still easy to read and safe.

Chain of responsibility vs state pattern

These patterns can look similar, especially when the input is an enum.

The difference is intent.

  • State pattern changes object behavior depending on state and usually models transitions between states.
  • Chain of Responsibility routes a request through handlers until one of them handles it.

In this article, we are not storing a “current state object” and switching behavior based on it. We are routing a single request through a pipeline of rules. That is a chain.

Common pitfalls

Handlers doing too much

A handler should not become a mini service that performs five checks and coordinates multiple flows. If it grows, it is usually a sign you need more handlers.

Silent failures

If no handler processes the request, your logic becomes implicit. Use a terminal handler if you expect the chain to always return an action.

Unclear ordering rules

If ordering matters, make it obvious in code. If ordering should not matter, you might not need CoR at all.

When I would use this pattern

In iOS projects, CoR fits nicely in areas like:

  • deep link routing
  • access control checks
  • request validation pipelines
  • feature gate checks
  • UI flow selection based on conditions

The rule of thumb is simple: if you see a growing conditional block that decides “what to do next”, a chain is worth considering.

Closing thoughts

Chain of Responsibility is a small pattern with a big practical payoff. It does not require complex architecture. It just gives you a clean way to express a set of rules as a pipeline.

And more importantly, it makes adding the next rule boring. That is exactly what you want.

I hope this was interesting and not too boring for you. The following article will explore another design pattern called the Command.