Dan Levy's Avatar DanLevy.net

Master of Pipelines: Passing State

Hello Closure, My Old Friend.

Master of Pipelines: Passing State

Master of Pipelines: Passing State

Have you run into challenges passing state around using Functional Pipelines?

The organization (or lack thereof) of your code directly impacts the ease with which state is passed around.

In this article we’ll explore an effective technique for passing state through a pipeline. Along the way we’ll improve the organization & readability of our code.

The following “real” snippet will be our focus for this article: A checkout function, which accepts a userId and an array of products. It returns a Promise-chain which executes 4 functions in sequence.

const checkout = (userId: number, products: number[]) => {
return getProductsSubtotal(userId, products)
.then(subTotal => applyTaxes(userId, subTotal))
.then(total => purchaseProducts(userId, total))
.then(result => sendReceipt(userId, result));
};

Wait a sec, this code is actually pretty decent, as far as pipelines in JS are concerned!

It does suffer from a few subtle issues which can combine into more substantial problems.

One problem is that we’re repeatedly passing userId around to each (logically-related) function. Now combine that with another issue that’s easily missed by developers & TypeScript too: flipping the numeric arguments easily creates a silent bug. (See applyTaxes and purchaseProducts. Was it userId or amount that goes first?)

Before we decide how to improve this code, let’s identify some pros/cons.

Pros & Cons

Pros

Cons

Solution, Part 1: Make a module!

This technique is about organizing related functions into a single module (e.g. CartHelpers.) It doesn’t demand a specific pattern. Explore factory functions, Classes, Closures, Mixins, etc. Find what makes sense for your project & team.

CartHelpers Factory

Example of a CartHelpers module, where userId is passed in once, and all methods are single-argument.

const CartHelpers = (userId: number) => {
return {
getProductsSubtotal: products => getProductsSubtotal(userId, products),
applyTaxes: subTotal => applyTaxes(userId, subTotal),
purchaseProducts: total => purchaseProducts(userId, total),
sendReceipt: invoice => sendReceipt(userId, invoice)
};
};

CartHelpers Class

If classes are your thing, it’s easy to adapt:

class CartHelpers {
constructor(userId) {
this.userId = userId;
}
getProductsSubtotal = products => getProductsSubtotal(this.userId, products);
applyTaxes = subTotal => applyTaxes(this.userId, subTotal);
purchaseProducts = total => purchaseProducts(this.userId, total);
sendReceipt = invoice => sendReceipt(this.userId, invoice);
}

Some immediate benefits:

By grouping related functions, we create an opportunity to reduce exposed surface area (e.g. checkout(), CartHelpers ’public’ methods.)

Less surface area === less cognitive load, better testing & maintainability. Design systems with intention & focus. ✨

Checkout & CartHelpers Usage

Let’s see how the checkout() function looks now:

export const checkout = ({ userId, products }) => {
const cart = CartHelpers(userId);
return Promise.resolve(products)
.then(products => cart.getProductsSubtotal(products))
.then(subTotal => cart.applyTaxes(subTotal))
.then(total => cart.purchaseProducts(total))
.then(result => cart.sendReceipt(result));
};
Checkout with further improvements

Can it be improved further? Yes! We don’t have to repeat arguments at all!

When a functions’ arguments are provided by the output of prior functions, you can simplify the code even further.

export const checkout = ({ userId, products }) => {
const cart = CartHelpers(userId);
// 🌈 Functions stack like Lego & read like normal "Human Words!" 💅
return Promise.resolve(products)
.then(cart.getProductsSubtotal)
.then(cart.applyTaxes)
.then(cart.purchaseProducts)
.then(cart.sendReceipt);
};

If it feels unnatural combining parameters into single (object) arguments, consider breaking up your functions OR combining them into more appropriately scoped modules.

Where to Start?

Find related functions, and group them together. (e.g. CartHelpers.)

Part of the challenge when finding possible logical modules is identifying related code in the first place.

One neat trick: Find repetition in function parameters. Ask is there a relationship at play? Or an underlying responsibility?

While there is no single “right answer” to designing modules, it helps to identify 2-3 options for organization - draw an outline, write “fantasy” code, ask “does it spark joy?”

You might feel cart.sendReceipt() doesn’t belong with payment-related methods. Perhaps customerNotifications.sendReceipt() is a better home for customer messaging. If CartHelper is high-enough in importance, it may act as a controller internally calling all necessary services, like customerNotifications.

How do you know if you’re helping?

If readability doesn’t suffer as you eliminate ad-hoc arguments, CONGRATULATIONS!!! You’ve likely built a module with a clear and durable scope!

So, that begs the question, where do we add functionality?

In my experience there are 2 primary strategies to evaluate when adding functionality:

  1. Extend/refactor existing method. (When new code is close enough to existing code.)
  2. Create a new (5th) function at the desired place in the chain. (Assuming new code is unrelated to existing functions.)

Ultimately this makes it easier to decide where new functionality belongs. (e.g. cart.applyDiscounts(), cart.applyTaxes(), rewards.getBalance().)

Conclusion

Passing state through a complex pipeline can be tricky. However, with a little refactor practice, you’ll find yourself writing more readable code, with less cognitive load.

Questions? Comments? Concerns? Feel free to reach out @justsml or email.

Stay tuned for the next part in the series

We’ll explore externalizing state, and extending functionality in our module!

Edit on GitHubGitHub