Abstraction as a practice to write Clean Code

While there are many definitions of what amounts to clean code, I would personally go with its simplest form.

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

The book "Clean Code" By Robert C. Martin speaks about a lot of practices to keep the code clean. In this article, by using the abstraction method, we will see how we can bring a few of those practices to play.

Consider the following method written in Typescript that creates 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((cartItem) => !cartItem.isValid || cartItem.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');
  }
}

Although the above piece of code is small in size and works perfectly well, it is in no way simple to understand. If a developer in the near future is tasked to understand and modify this piece of code, they will have to read through every line to figure out what it does.

If you went through the above piece of code line by line, you would have figured out by now that it does the following

  • Validate the user id passed by checking the user record's presence and whether the user is active or not.
  • Validate if the cart items are valid
  • Check if a cart already exists for the user and delete it if it exists
  • Create a new cart

Let us see now how we can refactor the above code to make it more readable.

The first practice we will address is related to the length of the function. It is too long. A single function encapsulating the entire logic of code is a bad idea. We need to break it down into multiple smaller functions.

What is the ideal size of a function then? There is no valid answer. It should just be small enough that it does only one thing and the function's name should reflect what it does.

Keeping these two points in mind, let us refactor our code to the following

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

And just like that, we applied our first layer of abstraction. We pushed the complexity of the code one level down. When a developer encounters this function, they can easily understand what it does by just going through the names of the functions it calls in turn. It is then left to the developer to choose to drill deeper to understand how each of those sub-functions is implemented. But the important part is that they will be able to understand what the function does without going through each and every line or by reading comments.

This brings us to the third practice we are addressing. Write comments only if you cannot explain yourself through code. Don't excuse yourself from writing self-explanatory code by adding comments to the code.

Following would be the implementations of the new 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(userId: string) : Promise<void> {
  if(cartItems.filter((cartItem) => !cartItem.isValid || cartItem.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) : Promise<void> {
  await cartService.create(userId, cartItems); 
}

This is our first level of abstraction to improve readability. We can apply the same principle recursively to the new functions to improve their readability as well. Let's take the following function as an example.

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

The above function does two things:

  • Retrieves a cart from cartService
  • Deletes the cart if it is present

We can rewrite it as follows to introduce the second level 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> {
  return await getCartForuser(userId);
}

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

The developer can take a logical decision around how many levels of abstraction are needed for the code they are writing.

At this point, there are still various other practices of clean code that we can apply to our code. For example, !userRecord || !userRecord.isActive can be assigned to a variable as const isUserPresentAndActive = userRecord && userRecord.isActive to be more clear about the check we are making to validate user. But, we will stop here as this article is meant to be about abstraction as a method to write Clean Code.

Happy Coding!!

Did you find this article valuable?

Support Pavan Andhukuri by becoming a sponsor. Any amount is appreciated!