Software Design Principles: Single Level of Abstraction

Jun 28, 2023//-6 min

Single Level of Abstraction (SLA) is a software design principle that emphasizes the importance of having expressions at the same level of abstraction within a function, aiming to enhance readability. It also ensures that it conforms to the Single Responsibility (SR) principle.

In other articles related to this topic, they often jump into examples without explaining what abstraction level means. Fortunately, we won't skip the most important point.

What is Abstraction Level?

Every function performs an action, which often includes sub-actions. The level of these sub-actions indicates the abstraction level. That's essentially what it is.

Let's illustrate this with an example of a coffee machine.

single-level-of-abstraction-001
single-level-of-abstraction-001

If we want to buy a coffee from a machine, we put the money in, choose the type we want, wait for it to be prepared, and take it. These steps are at the first level of abstraction because they are the basic steps for this case.

These user interactions trigger other actions in the background. During the coffee preparation phase, the machine takes the cup, adds coffee, pours hot water, and shuffles. These steps are at the second level of abstraction (the sub-actions of the money insertion are the same way). They are actions that the user who wants to buy coffee should not focus on. It would be an unnecessary detail if you put the cup in the machine or stir the coffee yourself. But if there is no cup in the machine, then you need to check the relevant functionality.

Let's see the instruction sequence dummy code that violates the principle.

1getCoffee() {
2  checkMoney();      // abstraction lvl 2
3  addToBallance();   // abstraction lvl 2
4  chooseCoffeType(); // abstraction lvl 1
5  getGlass();        // abstraction lvl 2
6  putCoffee();       // abstraction lvl 2
7  putWater();        // abstraction lvl 2
8  shuffleGlass();    // abstraction lvl 2
9  giveCoffee();      // abstraction lvl 1
10}

We don't add the balance ourselves or check the money, right? So this is abstracted from the user. Let's refactor the code to add extra abstraction.

1getCoffee() {
2  getMoney();        // abstraction lvl 1
3  chooseCoffeType(); // abstraction lvl 1
4  prepareCoffee();   // abstraction lvl 1
5  giveCoffee();      // abstraction lvl 1
6}

Code Examples

calculateSum()
conforms to the SLA principle, only including the details of calculating the sum. It defines a variable that holds the calculation result, adds the other numbers to it and returns it.

1public int calculateSum(int[] numbers) {
2  int sum = 0;
3
4  for (int num : numbers) {
5   sum += num;
6  }
7
8  return sum;
9}

But

validateUser()
contains different abstraction levels.
isValidEmail()
is at the second level of abstraction. The main purpose of the function is to check if the user is valid. However, when looking at the code, we also see the details of the validation process and error handling, indicating the presence of multiple abstraction levels. To compare their readability, take a closer look at the
validateUser()
function.

1public boolean validateUser(User user) {
2  if (user.getName().isEmpty()) {
3    System.out.println("Name cannot be empty.");
4    return false;
5  }
6
7  if (user.getEmail().isEmpty()) {
8    System.out.println("Email cannot be empty.");
9    return false;
10  }
11
12  if (!isValidEmail(user.getEmail())) {
13    System.out.println("Invalid email format.");
14    return false;
15  }
16
17  if (user.getPassword().isEmpty()) {
18    System.out.println("Password cannot be empty.");
19    return false;
20  }
21
22  if (user.getPassword().length() < 8) {
23    System.out.println("Password must be at least 8 characters long.");
24    return false;
25  }
26
27  return true;
28}

The refactored version of

validateUser()
is as follows.

1public boolean validateUser(User user) {
2  if (!hasValidName(user)) { // name validation details abstracted
3    logError("Invalid name"); // logging details abstracted
4    return false;
5  }
6
7  if (!hasValidEmail(user)) { // email validation details abstracted
8    logError("Invalid email"); // logging details abstracted
9    return false;
10  }
11
12  if (!hasValidPassword(user)) { // şifre validation details abstracted
13    logError("Invalid password"); // logging details abstracted
14    return false;
15  }
16
17  return true
18}

Even if the amount of code increases with new functions, pay attention to how easy it is to read and how easy it is to maintain. For example, imagine you are checking for age verification in the validation phase:

  • You examine the function.
  • You quickly see the validation steps and notice that age verification is missing.
  • If necessary, you create a validation function for age verification and integrate it into the code.

It's done. Since the abstraction is very sharp, you don't need to go through hundreds of lines of code to see which code verifies age by extracting password and email verification details.

Practice

Assume you have a function that calculates the total price of a product list, and take a minute to analyze why it violates the SLA principle. Try to refactor it yourself following the principle of No Pain, No Gain (my principle).

1public int calculateTotalCartPrice(CartItem[] cartItems) {
2  int totalPrice = 0;
3
4  for (CartItem cartItem : cartItems) {
5    totalPrice += cartItem.getPrice();
6
7    if (cartItem.isTaxable()) {
8      totalPrice += cartItem.getPrice() * 0.18;
9    }
10
11    if (cartItem.getCategory().equals("Electronics")) {
12      if (cartItem.getPrice() > 500) {
13        totalPrice -= 50;
14      }
15    }
16  }
17
18  return totalPrice;
19}

When we examine the function, we can see that it adds the product price to the total amount, adds the product tax to it and finally deducts the discount amount if available. We can split the code into three parts. Let's refactor it step by step:

  • The logic for calculating the product price within the loop in

    calculateTotalCartPrice()
    is at a different level of abstraction than the rest of the code. Let's extract it into a separate function.

    If a function contains a loop, it probably contains a piece of code that violates the Single Level of Abstraction.

1public int calculateTotalCartPrice(CartItem[] cartItems) {
2  int totalPrice = 0;
3
4  for (CartItem cartItem : cartItems) {
5    totalPrice += calculateCartItemPrice(cartItem);
6  }
7
8  return totalPrice;
9}
10
11private int calculateCartItemPrice(CartItem cartItem) {
12  int cartItemPrice = cartItem.getPrice();       // abstraction level 1
13
14  if (cartItem.isTaxable()) {
15    cartItemPrice += cartItem.getPrice() * 0.18; // abstraction level 2
16  }
17
18  if (cartItem.getCategory().equals("Electronics")) {
19    if (cartItem.getPrice() > 500) {             // abstraction level 2
20      cartItemPrice -= 50;
21    }
22  }
23
24  return cartItemPrice;
25}
  • We have successfully refactored the

    calculateTotalCartPrice()
    function, but the new function still has different levels of abstraction. The main action of this function is to return the price of the product, but the calculation of tax and discount are sub-actions.

    If you use an verb when voicing what your code does, there is a piece of code that you need to extract to the function. Let's look at the example above:

    • Get product price (this action is already the reason for creating this function)
    • Calculate tax (must be extracted to function)
    • Calculate discount (must be extracted to function)
    • Return finally price (this action is already the reason for creating this function)
1public int calculateTotalCartPrice(CartItem[] cartItems) {
2  int totalPrice = 0;
3
4  for (CartItem cartItem : cartItems) {
5    totalPrice += calculateCartItemPrice(cartItem);
6  }
7
8  return totalPrice;
9}
10
11private int calculateCartItemPrice(CartItem cartItem) {
12  int cartItemPrice = cartItem.getPrice();
13
14  if (cartItem.isTaxable()) {
15    cartItemPrice += calculateTax(cartItem.getPrice());
16  }
17
18  if (isElectronicItem(cartItem)) {
19    cartItemPrice -= calculateDiscount(cartItem.getPrice());
20  }
21
22  return cartItemPrice;
23}
24
25private int isElectronicItem(CartItem cartItem) {
26  return cartItem.getCategory().equals("Electronics");
27}
28
29private int calculateTax(int price) {
30  return price * 0.18;
31}
32
33private int calculateDiscount(int price) {
34  return price > 500 ? 50 : 0;
35}

As a result of the refactor, we get a clean code and the blessings of future generations.

We want to write our code in a way that minimizes bugs and can be easily flexed in the future. Principles like Single Level of Abstraction also encourage writing clean code by refining the details.