Molecules of business logic from tiny atoms
Tags: architecture, typescript, productivity, cleancode
Published: 2024-06-25 17:32:07

Some people asked me how to design their business logic after reading my article on [keeping your business code separate from the rest](https://dev.to/seasonedcc/keep-your-business-code-separate-from-the-rest-4ek2). Here's an example of how we do it.

Imagine we have an online shop with a page for our Magical T-Shirt. On that page, we want to show all the product variants (small, medium, large) plus product information, like name. Your customer has applied a coupon code to their cart, so we must also show the discounted price.

## Functions with a verb in their name

At [Seasoned](https://www.seasoned.cc/), we prefer functions with verbs in their name to classes and objects. So the first thing we'll do is create a function called `getProductPageData` that gathers all the information we need.

It will look completely different by the end of this article, but this is how it starts:

```ts
function getProductPageData(productId: string, couponCode: string) {
  // This would come from your database
  const product = { id: productId, name: 'Magical T-Shirt' }

  // This would come from your database
  const variants = [
    { productId, sku: 'small', name: 'Small', price: 8.99 },
    { productId, sku: 'medium', name: 'Medium', price: 10.99 },
    { productId, sku: 'large', name: 'Large', price: 12.99 },
  ]

  // This would come from your database
  const coupon = { code: couponCode, discount: 10 }

  return {
    product,
    coupon,
    variants: variants.map((variant) => ({
      ...variant,
      priceWithDiscount: variant.price * (1 - coupon.discount / 100),
    })),
  }
}
```

## A folder dedicated to business logic

This code lives in a `business` folder and it should not have any knowledge about the framework you're using. Ideally, it should work as well for a mobile app as it does for a website. All the code dealing with platform-specific APIs or framework-specific implementation should live elsewhere.

## Dealing with errors

Our `getProductPageData` function could fail. For instance, every query we make to our database can throw an error. So far, our code does nothing to deal with that.

Instead of adding multiple `try/catch` statements, we'll establish a pattern to deal with exceptions. For that, let me introduce you to [Composable Functions](https://github.com/seasonedcc/composable-functions), a library we created to make function composition easy and safe.

Composable functions do much more, but they help us deal with errors by doing the `try/catch` for us and returning the result in a type-safe way. Imagine this silly function call:

```ts
const getLength = (text: any) => text.length

getLength(null)
// TypeError: Cannot read properties of null (reading 'length')
```

If you wrap it with `composable`, it will never throw an error. Instead, it will return the result with the exception, like:

```ts
const getLength = composable((text: any) => text.length)

const result = await getLength(null)
// result equals
// {
//   success: false,
//   errors: [<Error object>]
// }
```

If it succeeds, it will return the result of the function inside `data`:

```ts
const getLength = composable((text: any) => text.length)

const result = await getLength('abc')
// result equals
// {
//   success: true,
//   data: 3,
//   errors: []
// }
```

This pattern forces us to remember to deal with errors. To access `result.data`, we must first check if `result.success` is true. Otherwise, TypeScript will give us an error.

This is how our `getProductPageData` function looks like when wrapped with `composable`:

```ts
const getProductPageData = composable(
  (productId: string, couponCode: string) => {
    // This would come from your database
    const product = { id: productId, name: 'Magical T-Shirt' }

    // This would come from your database
    const variants = [
      { productId, sku: 'small', name: 'Small', price: 8.99 },
      { productId, sku: 'medium', name: 'Medium', price: 10.99 },
      { productId, sku: 'large', name: 'Large', price: 12.99 },
    ]

    // This would come from your database
    const coupon = { code: couponCode, discount: 10 }

    return {
      product,
      coupon,
      variants: variants.map((variant) => ({
        ...variant,
        priceWithDiscount: variant.price * (1 - coupon.discount / 100),
      })),
    }
  },
)
```

## Extracting smaller functions

Over time, as we get to know our needs for reusing specific parts of code, we start to extract smaller functions. However, be careful not to extract them too early, as [the wrong abstraction is worse than code duplication](https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction). In other words, [avoid hasty abstractions](https://kentcdodds.com/blog/aha-programming) and only extract smaller functions when the right abstraction emerges.

In our example, let's imagine we have to get product info in multiple places of our codebase. We also need to list a product's variants, get coupon info, and apply a discount to a list of variants on several occasions for different business purposes.

With that, the following tiny atoms of business logic emerged:

```ts
const getProduct = composable(({ productId }: { productId: string }) => {
  // This would come from your database
  return { id: productId, name: 'Magical T-Shirt' }
})

const getVariants = composable(({ productId }: { productId: string }) => {
  // This would come from your database
  return [
    { productId, sku: 'small', name: 'Small', price: 8.99 },
    { productId, sku: 'medium', name: 'Medium', price: 10.99 },
    { productId, sku: 'large', name: 'Large', price: 12.99 },
  ]
})

// Usually, this type would come from your database lib
// You don't need to understand it now, but it's the type for
// an individual variant.
type Variant = UnpackData<typeof getVariants>[number]

const getCoupon = composable(({ couponCode }: { couponCode: string }) => {
  // This would come from your database
  return { code: couponCode, discount: 10 }
})

const applyDiscountToVariants = composable(
  ({ variants, discount }: { variants: Variant[]; discount: number }) =>
    variants.map((variant) => ({
      ...variant,
      priceWithDiscount: variant.price * (1 - discount / 100),
    })),
)
```

> **Aside**
>
> — Why are we using a single object as the argument for our small functions? Why is it `{ productId }: { productId: string }` instead of simply `productId: string`?
>
> — Thank you for noticing! We'll get to that soon :)

## Using our atoms

Now let's rewrite `getProductPageData` to use these atomic functions instead of duplicating their code. An initial version looks like this:

```ts
const getProductPageData = composable(
  async (productId: string, couponCode: string) => {
    // Notice that composable functions are always async
    const productResult = await getProduct({ productId })

    if (!productResult.success) {
      throw new Error('Could not find product')
    }

    const product = productResult.data

    const variantsResult = await getVariants({ productId })

    if (!variantsResult.success) {
      throw new Error('Could not find variants')
    }

    const variants = variantsResult.data

    const couponResult = await getCoupon({ couponCode })

    if (!couponResult.success) {
      throw new Error('Could not find coupon')
    }

    const coupon = couponResult.data

    return { product, coupon, variants }
  },
)
```

Well, it's not looking that good yet. The good part is that composable functions force us to deal with error cases, so we _had_ to make all the checks above. But there is a better way. If we rewrite the function to use [fromSuccess](https://github.com/seasonedcc/composable-functions/blob/main/API.md#fromsuccess), it looks much cleaner:

```ts
const getProductPageData = composable(
  async (productId: string, couponCode: string) => {
    const product = await fromSuccess(getProduct)({ productId })
    const variants = await fromSuccess(getVariants)({ productId })
    const coupon = await fromSuccess(getCoupon)({ couponCode })

    return { product, coupon, variants }
  },
)
```

It's starting to look better! `fromSuccess` does the check and throws the error for us when a function fails.

## Going molecular

We could consider our work done with the code we have so far. It is doing what it is supposed to do, it's broken down into tiny atoms of business logic, and it's dealing with errors properly.

But we believe there's an even better way to bond our atoms. Right now, they're not truly bonded. They are being used separately, one at a time, and we're manually piecing together their results. If we use [function composition](https://www.freecodecamp.org/news/function-composition-in-javascript/), we can bond our atoms to create molecular functions.

Before we get back to our online shop, let's go over the basics of composition with our silly `getLength` example from the beginning of the article.

Imagine you want to get the length of a string but return it as a string. This is the result we're expecting:

```ts
const result = await getLengthAsString('abc')
// result equals
// {
//   success: true,
//   data: '3', ⬅️ Notice this is a string 👀
//   errors: []
// }
```

Our initial implementation of `getLengthAsString` could be:

```ts
const getLengthAsString = 
  composable((text: string) => String(text.length))
```

But we already have a `numberToString` composable function that converts a number to a string, so we want to use it:

```ts
const numberToString = composable((number: number) => String(number))

const getLengthAsString = composable((text: string) =>
  fromSuccess(numberToString)(text.length),
)
```

Now it's time to add composition. For composing functions, we need [combinators](https://github.com/seasonedcc/composable-functions/blob/main/API.md#combinators). Combinators are functions that receive composable functions as input and return another composable function.

We're going to talk about a few combinators in this article, and the first one is [pipe](https://github.com/seasonedcc/composable-functions/blob/main/API.md#pipe). `pipe` creates a chain of composable functions that will be called in sequence, always passing the result of the last function called as the input to the next.

Here's our `getLenghAsString` using pipe:

```ts
const getLength = composable((text: string) => text.length)
const numberToString = composable((number: number) => String(number))
const getLengthAsString = pipe(getLength, numberToString)

const result = await getLengthAsString('abc')
// result equals
// {
//   success: true,
//   data: '3', ⬅️ It is a string 🎉
//   errors: []
// }
```

## Why is it better this way?

There are a few reasons why the version with `pipe` is better than the one with `fromSuccess`:

First, it's easier to change. Want to return both the numeric and the string versions of the result? Just add a [map](https://github.com/seasonedcc/composable-functions/blob/main/API.md#map) to the composition:

```ts
const getLengthAsStringAndNumber = pipe(
  getLength,
  map(numberToString, (result, input) => ({
    asNumber: input,
    asString: result,
  })),
)

const result = await getLengthAsStringAndNumber('abc')
// result equals
// {
//   success: true,
//   data: { asNumber: 3, asString: '3' },
//   errors: []
// }
```

Notice how we didn't have to change anything in our atomic functions. They remained the same. Only the composition (our molecule) changed.

Second, it's easier to test. You can write one set of tests for `getLength`, another one for `numberToString`, and just enough tests on `getLengthAsString` to make sure the composition worked.

Lastly, all compositions are type-safe from end to end. For example, if we try to `pipe` a function that returns a string into a function that expects a number, the composition will fail.

## Back to our online store

Now it's time to create our `getProductPageData` with compositions. Here's one way of doing it:

```ts
const getRawData = collect({
  product: getProduct,
  variants: getVariants,
  coupon: getCoupon,
})

const getRawDataWithDiscount = map(getRawData, (data) => ({
  ...data,
  discount: data.coupon.discount,
}))

const getRawDataWithDiscountedVariants = sequence(
  getRawDataWithDiscount,
  applyDiscountToVariants,
)

const getProductPageData = map(
  getRawDataWithDiscountedVariants,
  ([{ product, coupon }, variants]) => ({ product, coupon, variants }),
)
```

It looks daunting, I know. But let's walk through it together, one step at a time.

First, we [collect](https://github.com/seasonedcc/composable-functions/blob/main/API.md#collect) all the raw data we need on `getRawData`. This function receives `{ productId: string; couponCode: string }` and returns an object with `product`, `variants`, and `coupon`.

Then we [map](https://github.com/seasonedcc/composable-functions/blob/main/API.md#map) the raw data on `getRawDataWithDiscount`. This function receives the same `{ productId: string; couponCode: string }` input and returns an object with `product`, `variants`, `coupon`, and `discount`.

The reason we add the `discount` to the root of our object is because our `applyDiscountToVariants` takes a list of `variants` and the `discount` as input, not the `coupon`. In the real world, it's very common to have an atomic function that does what you need but require you to "massage your input" a little in before you can call it.

Next, we [sequence](https://github.com/seasonedcc/composable-functions/blob/main/API.md#sequence) getting the raw data with discount and applying the discount to our variants on `getRawDataWithDiscountedVariants`. It also receives `{ productId: string; couponCode: string }`. Then, it returns a [tuple](https://www.w3schools.com/typescript/typescript_tuples.php) with the result of `getRawDataWithDiscount` as the first element, and the result of `applyDiscountToVariants` as the second.

Finally, we map the result of `getRawDataWithDiscountedVariants` to get rid of the tuple and return an object with the final data structure we want on `getProductPageData`.

## Calling our function

Now you can call your business function from the outside world. This usually means calling it from inside a controller, a loader, or any other method for gathering data.

```ts
const result = await getProductPageData({
  productId: '123',
  couponCode: '10OFF',
})

if (!result.success) {
  // 🚨 Any errors thrown in any of the composed functions
  // will be on result.errors
  result.errors.forEach(error => console.log(error))

  return
}

console.log(result.data)

// {
//   product: { id: '123', name: 'Magical T-Shirt' },
//   coupon: { code: '10OFF', discount: 10 },
//   variants: [
//     {
//       productId: '123',
//       sku: 'small',
//       name: 'Small',
//       price: 8.99,
//       priceWithDiscount: 8.091000000000001
//     },
//     {
//       productId: '123',
//       sku: 'medium',
//       name: 'Medium',
//       price: 10.99,
//       priceWithDiscount: 9.891
//     },
//     {
//       productId: '123',
//       sku: 'large',
//       name: 'Large',
//       price: 12.99,
//       priceWithDiscount: 11.691
//     }
//   ]
// }
```

As you can see, this version of `getProductPageData` receives an object with the product id and the coupon code instead of two arguments like we had on our initial version.

We commonly do that because it makes it easier to compose the functions. That's why our atomic functions also receive an object. For example, on `getRawDataWithDiscountedVariants` we can get the result of `getRawDataWithDiscount` and pass it to `applyDiscountToVariants` without transforming it, even if returns an object with more keys than what `applyDiscountToVariants` needs.

If our atomic functions expected multiple arguments instead of an object, we could transform our data at every stage to be exactly what the next function needs. But I think this is a good pattern that leads to more readable compositions.

## Thinking in composition

It takes a while to adjust our thought process to think of compositions instead of a sequence of function calls and if statements. To be honest, I'm not totally there yet. But the more I use composition, the more I write code that's maintainable and easy to test.

It will take you and your team some time and learning curve but once you start thinking in compositions, you can skip the intermediate functions above and write your compositions inline:

```ts
const getProductPageData = map(
  sequence(
    map(
      collect({
        product: getProduct,
        variants: getVariants,
        coupon: getCoupon,
      }),
      (data) => ({
        ...data,
        discount: data.coupon.discount,
      }),
    ),
    applyDiscountToVariants,
  ),
  ([{ product, coupon }, variants]) => ({ product, coupon, variants }),
)
```

That's it. I hope this method of writing business code helps you give business logic the priority it deserves and create more resilient codebases 🤓

Let me know how it works for you!