Chain of Responsibility — The Functional Way

Loïs Mansot
January 5, 2024
Blockchain

Introduction

In the realm of software design, certain concepts have stood the test of time, proving their value in building robust and flexible systems. Among these concepts, design patterns have often played a pivotal role, offering tried-and-true solutions to recurring problems in software development. One such pattern is the “Chain of Responsibility.”

The Chain of Responsibility is a behavioral design pattern that promotes the decoupling of senders and receivers of requests. In this pattern, a request is passed along a chain of handlers, each of which has the option to handle the request or pass it to the next handler in the chain. The key idea is that no single object needs to know which object will ultimately process the request, and it allows for dynamic composition of handlers.

This pattern is useful in scenarios where you want to achieve flexibility and avoid hardcoding the handling logic into a single class as cascading conditionals. It allows you to add or modify handlers without changing the client code that initiates the request.

In practice, a typical implementation of the Chain of Responsibility pattern consists of a chain of handler objects, each with a reference to the next handler in the chain. When a request is sent to the chain, it’s passed from one handler to the next until one of them successfully handles it or the end of the chain is reached.

Functional Approach

At Smart-Chain, our software engineers continually strive to harness the full potential of programming languages.

Our primary focus will be on introducing you to the world of functional programming in TypeScript, leveraging libraries like fp-ts to offer a more concise and expressive approach distinct from traditional patterns. We won’t be implementing the Chain of Responsibility pattern itself, but rather its functional variant based on list catamorphism and monoids.

List catamorphism with first monoid

The functional approach to the Chain of Responsibility pattern, based on list catamorphism with the first monoid, is a way to handle a series of operations or responsibilities in a functional and composable manner. Let’s break down the core ideas involved:

  • List Catamorphism: A catamorphism is a generalization of the fold operation that’s commonly used in functional programming. It allows you to traverse and process elements in a list while accumulating a result. In the context of the Chain of Responsibility, list catamorphism provides a way to traverse a list of handlers and apply a function to each of them, potentially accumulating a final result.

  • First Monoid: A monoid is a mathematical structure consisting of a set, an associative binary operation, and an identity element. In this case, the “first” monoid represents the idea of stoping with the first successful handler in the chain. The binary operation combines two handlers results, and the identity element represents the result of a skipped no-op handler. This monoid helps us accumulate and choose the first valid result while maintaining the monoidal properties, associativity and identity.

The key principle for establishing a valid monoid in this context involves representing the result, including potential failures, using the algebraic data type Option<A>. This data type represents the presence or absence of a value. It has two variants: None, indicating the absence of a value, and Some<A>, representing the presence of a value. None acts as a failure case, while Some<A> encapsulates the successful result, providing a systematic approach to handle both scenarios.

Here is an implementation using TypeScript and the fp-ts library, using getMonoid from Option and concatAll from Monoid. Here, getMonoid is returning the left-most non-None value. If both operands are Some then the inner values are concatenated using the provided Semigroup. The S.first semigroup have a concat operation that always return the first argument.

Let’s explore two working examples: one involving a list that includes both Some and None elements, and the other with a list that contains only None elements.

import * as F from "fp-ts/function"
import * as M from "fp-ts/Monoid"
import * as O from "fp-ts/Option"
import * as S from "fp-ts/Semigroup"
import assert from "assert"

/**
 * First Monoid for the Option type
 */
export const getMonoidFirst =
    <T>(): M.Monoid<O.Option<T>> =>
        O.getMonoid(S.first<T>())

/**
 * Left-most non-none value of an array of Option
 */
export const firstOption:
    <T>(xs: ReadonlyArray<O.Option<T>>) => O.Option<T> =
        M.concatAll(getMonoidFirst())

/**
 * Main function for testing
 */
export const main =
    (): void => {
        assert.deepStrictEqual(
            firstOption<number>([O.none, O.some(1), O.some(2)]),
            O.some(1)
        )
        assert.deepStrictEqual(
            firstOption<number>([O.none]),
            O.none
        )
    }

In this TypeScript implementation, we are using eta reduction and currying. See the flow function from fp-ts, that helps function transformations with eta conversion besides the capability of pipe for function composition. Currying is advantageous as it facilitates partial application and enables eta-conversion by naturally breaking down functions into a series of single-argument functions, which can make it easier to identify and remove unnecessary parameters.

Generalization to asynchronous handler

While the previous straightforward approach works well for synchronous operations, it falls short when dealing with asynchronous handlers that require a TaskOption<A>. To tackle asynchronous processing effectively, we’ll introduce the concepts of Task and Alternative.

Unlike traditional JavaScript promises, TaskOption<A> and TaskEither<E, A> are designed to handle errors in a more controlled and functional manner while allowing you to build complex asynchronous workflows, avoiding the practice of throwing exceptions and, instead, explicitly representing failures through their respective types.

The Alternative type class is a fundamental abstraction that represents computations that can be combined in an associative way. It’s closely related to other type classes like Monoid and Applicative. The Alternative type class provides a mechanism for combining computations composed of two or more alternatives, where these alternatives are attempted one after the other until a meaningful result is obtained. This enables a powerful way to express and manage branching logic in a consistent and functional manner.

Let’s explore an illustrative example of an implementation that leverages the Alternative type class and TaskOption to showcase how we can elegantly handle branching asynchronous computations.

import * as A from "fp-ts/Alternative"
import * as Console from "fp-ts/Console"
import * as F from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as T from "fp-ts/Task"
import * as TO from "fp-ts/TaskOption"
import assert from "assert"

/**
 * First non-none result of a TaskOption sequence
 */
export const firstTaskOption:
    <T>(fs: Array<TO.TaskOption<T>>) => TO.TaskOption<T> =
        A.altAll(TO.Alternative)

/**
 * Main function for testing
 */
export const main =
    async (): Promise<void> => {
        const log:
            <T>(a: T) => TO.TaskOption<void> =
                F.flow(Console.log, T.fromIO, T.delay(500), TO.fromTask)

        const handler =
            <T>(n: number, a: O.Option<T>): TO.TaskOption<T> =>
                F.pipe(
                    log(`Handler ${n}: Step 1/2`),
                    TO.chain(() => log(`Handler ${n}: Step 2/2`)),
                    TO.chain(() => TO.fromOption(a))
                )

        assert.deepStrictEqual(
            await firstTaskOption([
                handler(0, O.none),
                handler(1, O.some(1)),
                handler(2, O.some(2)),
            ])(),
            O.some(1)
        )
    }

The handler function logs two asynchronous steps, indicating its progress, and returns a possibly failing result. It operates within the context of TaskOption<A>, representing an asynchronous computation that may either succeed or fail depending on an Option<A> argument.

It’s important to highlight that the alt operation employed in the implementation of altAll accepts a second argument that is lazy. This feature enables subsequent handlers to be skipped if the first handler has already succeeded, showcasing the primary capability of the Alternative type class.

When executing the tests, it becomes evident that the last handler is not executed because the second handler has already succeeded with O.some(1). This is visually demonstrated in the logged output:

Handler 0: Step 1/2
Handler 0: Step 2/2
Handler 1: Step 1/2
Handler 1: Step 2/2

Collecting handler errors

In this section, we’ll delve into the behavior of TaskEither when handling errors and introduce a variation frequently referred to as Validation.

While TaskEither is effective for asynchronous computations with potential errors, it falls short when you need to collect multiple errors. The default Alt instance for Either returns the last encountered error, but if you want to accumulate all errors, you can use Validation with a Semigroup to achieve comprehensive error aggregation via concatenation.

The key idea is to map left Either results by packaging errors as array singletons. This approach leverages the monoid instance of array to facilitate the collection of errors.

The provided code introduces altValidation from the EitherT error monad transformer, along with a zeroM no-op default. This combination allows us to reduce the handler list in precisely the same manner as the previous approach, with all errors accumulated.

Part One

import * as Array from "fp-ts/Array"
import * as Console from "fp-ts/Console"
import * as E from "fp-ts/Either"
import * as ET from "fp-ts/EitherT"
import * as F from "fp-ts/function"
import * as HKT from "fp-ts/HKT"
import * as Monad from "fp-ts/Monad"
import * as Monoid from "fp-ts/Monoid"
import * as TE from "fp-ts/TaskEither"
import * as T from "fp-ts/Task"
import assert from "assert"

/**
 * Get an empty left value from a monoid
 */
export const zero =
    <E, A>(m: Monoid.Monoid<E>): E.Either<E, A> =>
        E.left(m.empty)

/**
 * Wrap an empty left value from a monoid in a monad
 */
export const zeroM =
    <E, A, M extends HKT.URIS>(monad: Monad.Monad1<M>):
            ((monoid: Monoid.Monoid<E>) => HKT.Kind<M, E.Either<E, A>>) =>
        F.flow(
            zero<E, A>,
            monad.of
        )

/**
 * First non-left result of an EitherT sequence, using left concatentation
 */
export const altAllValidation =
    <E, A, M extends HKT.URIS>(monad: Monad.Monad1<M>) => (monoid: Monoid.Monoid<E>):
            ((fs: Array<HKT.Kind<M, E.Either<E, A>>>) => HKT.Kind<M, E.Either<E, A>>) =>
        Array.reduce(
            zeroM<E, A, M>(monad)(monoid),
            (acc, cur) =>
                ET.altValidation(monad, monoid)(() => cur)(acc)
        )

/**

Part Two

 * First non-left result of a TaskEither sequence, using left concatentation
 */
export const firstTaskValidation =
    <E, A>(monoid: Monoid.Monoid<E>):
            ((fs: Array<T.Task<E.Either<E, A>>>) => T.Task<E.Either<E, A>>) =>
        altAllValidation<E, A, T.URI>(T.Monad)(monoid)

/**
 * Main function for testing
 */
export const main =
    async (): Promise<void> => {
        const log:
            <T>(a: T) => TE.TaskEither<never, void> =
                F.flow(Console.log, T.fromIO, T.delay(500), TE.fromTask)

        const handler =
            <E, A>(n: number, a: E.Either<E, A>): TE.TaskEither<E, A> =>
                F.pipe(
                    log(`Handler ${n}: Step 1/2`),
                    TE.chain(() => log(`Handler ${n}: Step 2/2`)),
                    TE.chain(() => TE.fromEither(a))
                )

        assert.deepStrictEqual(
            await firstTaskValidation(Array.getMonoid<string>())(
                Array.map(TE.mapLeft(Array.of<string>))([
                    handler<string, never>(0, E.left("e0")),
                    handler<string, never>(1, E.left("e1")),
                ])
            )(),
            E.left(["e0", "e1"])
        )
    }

In the example provided, both handlers are executed, and their respective log lines are recorded. However, any errors they encounter are gathered into a unified error array as the resulting output.

Conclusion

In this article, we’ve delved into how functional techniques provide us with alternatives to the Chain of Responsibility design pattern. This demonstrates the versatility of functional programming in offering different approaches to solving similar problems.

We’ve demonstrated that by leveraging concepts like list catamorphism and monoid, we can create a more expressive and concise alternative. In addition, it’s worth noting that the functional programming style solutions enhance type safety by ensuring that all handlers have the same type and represent errors consistently.

This approach extends beyond just the Chain of Responsibility pattern. Functional programming empowers us to sidestep various design patterns by providing a high degree of composability. These techniques, rooted in concepts like monoids, alternatives, and monads, allow us to adapt and create numerous useful variations while maintaining consistency in our functional codebase. This flexibility showcases the power of functional programming in achieving elegant and adaptable solutions.

Written by
Loïs Mansot
Blockchain engineer