Abstraction as a Practice to Write Clean Code

A practical refactoring example that uses abstraction to make code easier to read and change—by pushing complexity down and making intent obvious.

Cover image for the clean code abstraction article

Originally published on Medium: Abstraction as a practice to write Clean Code

While there are many definitions of what amounts to clean code, I’d go with the simplest.

Clean code is code that is easy to understand and modify.

Robert C. Martin’s Clean Code covers many practices. In this article, I’m focusing on abstraction as a technique to bring a few of those practices into play.

Consider the following TypeScript method that creates (or replaces) a cart:

async function createOrReplaceCart(
  userId: string,
  cartItems: Array<CartItem>
): Promise<void> {
  const userRecord = await userRepository.findOne(userId);

  if (userRecord && userRecord.isActive) {
    if (cartItems.filter((ci) => !ci.isValid || ci.quantity < 1).length === 0) {
      const existingCart = await cartService.getCart(userId);
      if (existingCart) {
        await cartService.deleteCart(userId);
      }
      await cartService.create(userId, cartItems);
    } else {
      throw Error('Invalid Cart Items');
    }
  } else {
    throw Error('Invalid User Id');
  }
}

It’s small and works, but it’s not easy to read quickly. If someone needs to modify it later, they’ll have to walk through nested conditionals line-by-line to understand intent.

If you unpack it, it does four things:

  • Validate the user id (user exists and is active)
  • Validate cart items
  • Delete an existing cart if present
  • Create a new cart

First layer of abstraction: make intent obvious

One practice we can address immediately is function size. A single function encapsulating all the logic is hard to scan. There isn’t a universal “ideal” size, but a function should generally do one thing and its name should reflect that.

Refactor to:

async function createOrReplaceCart(
  userId: string,
  cartItems: Array<CartItem>
): Promise<void> {
  await validateUserId(userId);
  await validateCartItems(cartItems);
  await clearCartIfAlreadyExists(userId);
  await createCart(userId, cartItems);
}

Now a developer can understand the function by reading the names. They can choose to drill into details only when needed.

This also reinforces another common guideline:

Write comments only if you cannot explain yourself through code.

Implementations of the extracted functions

async function validateUserId(userId: string): Promise<void> {
  const userRecord = await userRepository.findOne(userId);
  if (!userRecord || !userRecord.isActive) {
    throw Error('Invalid User Id');
  }
}

async function validateCartItems(cartItems: Array<CartItem>): Promise<void> {
  if (cartItems.filter((ci) => !ci.isValid || ci.quantity < 1).length > 0) {
    throw Error('Invalid Cart Items');
  }
}

async function clearCartIfAlreadyExists(userId: string): Promise<void> {
  const existingCart = await cartService.getCart(userId);
  if (existingCart) {
    await cartService.deleteCart(userId);
  }
}

async function createCart(userId: string, cartItems: Array<CartItem>): Promise<void> {
  await cartService.create(userId, cartItems);
}

Second layer of abstraction (optional)

You can apply the same technique recursively. Take:

async function clearCartIfAlreadyExists(userId: string): Promise<void> {
  const existingCart = await cartService.getCart(userId);
  if (existingCart) {
    await cartService.deleteCart(userId);
  }
}

It does two things:

  • retrieve the cart
  • delete it if present

You can rewrite it with one more layer of abstraction:

async function clearCartIfAlreadyExists(userId: string): Promise<void> {
  const existingCart = await getCartForUser(userId);
  await deleteCartIfExists(userId, existingCart);
}

async function getCartForUser(userId: string): Promise<Cart | null> {
  return await cartService.getCart(userId);
}

async function deleteCartIfExists(userId: string, existingCart: Cart | null): Promise<void> {
  if (existingCart) {
    await cartService.deleteCart(userId);
  }
}

How many layers you apply is a judgement call — do enough to make intent easy to follow without over-fragmenting.

Happy coding.