DEV Community

Cover image for Molecules of business logic from tiny atoms
Daniel Weinmann for Seasoned

Posted on

Molecules of business logic from tiny atoms

Some people asked me how to design their business logic after reading my article on keeping your business code separate from the rest. 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, 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:

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),
    })),
  }
}
Enter fullscreen mode Exit fullscreen mode

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, 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:

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

getLength(null)
// TypeError: Cannot read properties of null (reading 'length')
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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:

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),
      })),
    }
  },
)
Enter fullscreen mode Exit fullscreen mode

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. In other words, avoid hasty abstractions 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:

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),
    })),
)
Enter fullscreen mode Exit fullscreen mode

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:

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 }
  },
)
Enter fullscreen mode Exit fullscreen mode

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, it looks much cleaner:

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 }
  },
)
Enter fullscreen mode Exit fullscreen mode

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, 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:

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

Our initial implementation of getLengthAsString could be:

const getLengthAsString = 
  composable((text: string) => String(text.length))
Enter fullscreen mode Exit fullscreen mode

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

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

const getLengthAsString = composable((text: string) =>
  fromSuccess(numberToString)(text.length),
)
Enter fullscreen mode Exit fullscreen mode

Now it's time to add composition. For composing functions, we need 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. 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:

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: []
// }
Enter fullscreen mode Exit fullscreen mode

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 to the composition:

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: []
// }
Enter fullscreen mode Exit fullscreen mode

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:

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 }),
)
Enter fullscreen mode Exit fullscreen mode

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

First, we 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 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 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 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.

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
//     }
//   ]
// }
Enter fullscreen mode Exit fullscreen mode

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:

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 }),
)
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)