3/19/2023, edited 7/2/2024

Composable side effects

⚙️⚙️⚙️

Context

Event driven libraries provide a sophisticated set of tools for dealing with async APIs. However, it can easily be overused. As we know, it is usually best to stick to the simplest possible solution that meets the requirements. From such a perspective sending an event - like a redux action or message int the stream - without any other reason beside triggering one function seems to be a pure overhead - decreasing readability and traceability without any clear benefit out of it. This is usually the case for low level building blocks that perform one particular side effect. We use such fine-grained functions for creating some higher abstractions that coordinate stuff. It may look like this:

async function coordinator() {
  const result1 = await sideEffect1();
  const result2 = await sideEffect2(result1);
  const result3 = await sideEffect3(result2);
  const result4 = await sideEffect4(result3);
  await sideEffect5(result4);
}

We have a sequence of side effects, where output of effect can be input for the next one. Now let’s try answer the question: is this implementation optimal? Well, it depends.

Heuristics

There are many reasons why we might consider our code as poorly designed and/or implemented, though I’d like to highlight two main classes of heuristics that I see especially useful for rating our side effects orchestrators:

  • implementation abstraction level, aka single level of abstraction principle (SLAP) - abstraction level in each function should be consistent, no mixing low level stuff with higher level implementation
  • implementation reusability, aka don’t repeat yourself (DRY)

In this post I’ll use only examples for DRY heuristics, because it’s easier to present it in a synthetic form and techniques that we will use for redesigning and refactoring are more or less universal and can be applied regardless the reason.

Techniques

Conditionals vs polymorphism

Let’s take an example where we have multiple coordinator functions for multiple use cases, like this:

async function coordinatorDefault() {
  const result1 = await sideEffect1();
  const result2 = await sideEffect2(result1);
  const result3 = await sideEffect3(result2);
  const result4 = await sideEffect4(result3);
  await sideEffect5(result4);
}

async function coordinatorX() {
  const result1 = await sideEffect1();
  const result2 = await sideEffect2X(result1); // 👈
  const result3 = await sideEffect3(result2);
  const result4 = await sideEffect4(result3);
  await sideEffect5(result4);
}

async function coordinatorY() {
  const result1 = await sideEffect1();
  const result2 = await sideEffect2Y(result1); // 👈
  const result3 = await sideEffect3Y(result2); // 👈
  const result4 = await sideEffect4(result3);
  await sideEffect5(result4);
  await sideEffect6Y(result4); // 👈
}

All those coordinator functions are similar, though there are some differences (pointed out).

So is it ok? If you ask me, usually it is, even in terms of DRY. Side effects are already encapsulated in separate functions. However, I bet that many reviewers would claim that it is not DRY enough. I understand, that in some cases it’s worth to keep particular things in one place (like some “important business logic”), for reasons similar to those for normalizing data in relational databases. So let’s discuss how to achieve that.

From my experience one can (too) often encounter a situation where DRY is achieved by using conditionals, like this:

async function coordinator() {
  const result1 = await sideEffect1();
  let result2;
  if (X) {
    result2 = await sideEffect2X(result1);
  } else if (Y) {
    result2 = await sideEffect2Y(result1);
  } else {
    result2 = await sideEffect2(result1);
  }
  let result3;
  if (Y) {
    result3 = await sideEffect3Y(result2);
  } else {
    result3 = await sideEffect3(result2);
  }
  const result4 = await sideEffect4(result3);
  await sideEffect5(result4);
  if (Y) {
    await sideEffect6Y(result4);
  }
}

Our example is simple, but I guess you already see, or can imagine, how complicated this approach may become, when more and more features (conditionals!) are added. So if I had to choose one of those I would rather pick the first one, less DRY, scenario-driven approach (coordinator per use case). This is because I prefer to keep each scenario simple and clear, even if some parts are not as DRY as it could be. The reason for that is that this way it’s much easier to read, maintain and extend the code, because we can focus on a specific use case. It helps avoiding the bugs while introducing changes 💚

In fact using ‘scenarios approach’ is nothing new - in a nutshell it’s a good, old strategy pattern, a polymorphic behavior well known in OOP world.

But what about DRY implementation of that “important business logic” then? Keeping the logic understandable is far more important than some repeating code, though using polymorphism (use case oriented) approach doesn’t mean that we can’t have DRY as well. So let’s talk about the techniques that we can use to achieve both things.

Refactoring techniques

Extracting a function

Our previous example wasn’t complicated, but I’ll use something even simpler in order to be precise while talking about refactoring techniques.

async function coordinator() {
  const result1 = await sideEffect1();
  const result2 = await sideEffect2(result1);
  const result3 = await sideEffect3(result2); // 👈
  const result4 = await sideEffect4(result3); // 👈
  await sideEffect5(result4);
}

Let’s assume that sideEffect2 and sideEffect3 are repeated in several scenarios, and/or those functions perform some significantly more detailed thing, meaning: the level of abstraction is much lower than other calls in coordinator function. Either way, we will solve that by extracting those side effects to another function, like this:

async function coordinator() {
  const result1 = await sideEffect1();
  const result2 = await coordinatorLowerLevel(result1); // 👈
  const result3 = await sideEffect4(result2);
  await sideEffect5(result4);
}

// this function abstraction level is lower and/or it's a commonly used piece of code
async function coordinatorLowerLevel(input) {
  const result = await sideEffect2(input);
  const result2 = sideEffect3(result);
  return result2;
}

Easy-peasy, we do that all the time. DRY, use case oriented code without conditionals - cool. It’s simple and very handy, but it can be used only if we have a sequence of statements, meaning operations must be called one after another.

Moreover, as we have already seen in our first example - life is rarely that simple. It is often the case that our side effects need to vary in multiple places, and scenario interleaves reusable parts with more specific ones.

Extracting a higher order function

Let’s assume, that our reusable part includes: sideEffect1, sideEffect4 and sideEffect5 (first, last but one and the last one). Fortunately js/ts allows us using higher order functions, so we can create a specific implementation factory like this:

type InAndOut<T> = (arg: T) => Promise<T>;

function createSpecificImpl(sideEffect3Impl: InAndOut) {
  return async function reusableCoordinator() {
    const result1 = await sideEffect1();
    const result2 = await sideEffect2(result1);
    const result3 = await sideEffect3Impl(result2); // customized effect 💉
    const result4 = await sideEffect4(result3);
    await sideEffect5(result4);
  };
}

const coordinator = createSpecificImpl(sideEffect3);

Cool, huh? But what if things are slightly more complicated, and we need more than one level of reusable logic and/or more than one piece of reusable code?

Tricky cases — deep nesting

Imagine, that there are 2 layers that we want to be extracted: sideEffect1+sideEffect5 and sideEffect2+sideEffect4

async function coordinator() {
  const result1 = await sideEffect1(); // reusable 🍐
  const result2 = await sideEffect2(result1); // reusable 🍎
  const result3 = await sideEffect3(result2); // customized effect 💉
  const result4 = await sideEffect4(result3); // reusable 🍎
  await sideEffect5(result4); // reusable 🍐
}

Good news: to solve this one, we can use the very same technique:

function createMiddleCoordinator(sideEffect3Impl: InAndOut) {
  return async function middleCoordinator(result1: number) {
    const result2 = await sideEffect2(result1);
    const result3 = await sideEffect3Impl(result2);
    const result4 = await sideEffect4(result3);
    return result4;
  };
}

function createOuter(nestedCoordinator: InAndOut) {
  return async function middleCoordinator() {
    const result1 = await sideEffect1();
    const result4 = await nestedCoordinator(result1);
    const result5 = await sideEffect5(result4);
  };
}

const coordinator = createOuter(createMiddleCoordinator(sideEffect3));

This example shows also one more thing: our reusable factories can be treated at the same time as dependencies — createMiddleCoordinator is a good example. It’s sort of like a class that implements two interfaces, a duplex stream that can handle input and emit an output.

Bonus tip

If you are into functional programming and there are more functions that you need to extract and compose like this, then I recommend considering using also some helper operator to achieve a better readability with point-free programming style. Here are some examples from popular libraries:

// lodash-fp flow
const coordinator = flow([
  createMiddleCoordinator,
  createOuter,
  // ...more higher order functions
])(sideEffect3);
// redux
const coordinator = compose(createOuter, createMiddleCoordinator)(sideEffect3);
// fpts
const coordinator = pipe(sideEffect3, createMiddleCoordinator, createOuter);

Summary

We have learned that if we have many conditional statements in our side effects coordinators (orchestrators), then it’s best to extract functions that represent less complicated flows. This helps keep it maintainable. Then, if we need to make our code more DRY we can:

  • extract functions that gather a sequence of side effects (easy, but limited)
  • create factories (higher order functions) that will be used to create functions from a template with placeholders for ‘moving parts’, by injecting the custom side-effects implementations.

All the examples here are using async-await, but I have also used those techniques to organize side effects with redux-saga (yields) and it served me well.

Moreover, I guess it might be also true and useful for some backend building blocks, like sagas, process managers and similar services that deal with orchestrating side effects.