The Single Level of Abstraction Principle (SLAP)

The Single Level of Abstraction Principle (SLAP) tells us that each method should work at a single level of abstraction, but what does that mean?

The Problem

Let’s look at the following code that prints the numbers 1 to 35. It’s a bit of a nonsensical example but it’ll do for the discussion that follows:

class Main {
  public static void main(String[] args) {
    printNumbersOneToTen();

    for(int i=11; i < 21; i++) {
      System.out.println(i);
    }

    System.out.println(21);

    if(22 % 11 == 0) {
      System.out.println(22);

      for(int i=23; i < 31; i++) {
        System.out.println(i);

        if(i == 30) {
            for(int j=31; j < 36; j++) {
            System.out.println(j);
          }
        }
      }
    }
  }

  private static void printNumbersOneToTen() {
    for(int i=1; i < 11; i++) {
      System.out.println(i);
    }
  }
}

In the example, the main method is working at different levels of abstraction. Printing the numbers 1 to 10 has been extracted to a method whilst printing the numbers 11 to 35 happens in main itself. What is happening here?

Different Levels of Abstraction

A method is a form of abstraction and one has been created for printing the numbers 1 to 10. We can say that it is more abstract than the rest of the details around it that are not abstracted by methods.

The details of printing the numbers 1 to 10 have been hidden away behind a method named printNumbersOneToTen. We should be able to get a good understanding of what a method does from its name alone. So If this method does what its name says then that’s all we need to know to continue reading through main.

By reading the method name “printNumbersOneToTen”, we’d have superficially processed all we need to know about printing the numbers 1 to 10 in one line. However, after we get passed the invocation of printNumbersOneToTen we start to delve into some very detailed steps of how the numbers 11 to 35 are printed.

To understand that process we have to read 27 lines of code. This is where the brain has to do extra work by jumping between the two levels of abstraction. On the one hand, we have a single line of code expressing the concept of printing the numbers 1 to 10, but then on the other, we have 27 lines of code that are a much more detailed description of how to print the numbers 11 to 35.

You can imagine this as follows, where the green block is a method invocation and the other coloured blocks are low-level details:

 

Same Level of Abstraction

What would be good is if we could get the main method to a point where everything is at the same level of abstraction. A place where it’s a composition of method calls that looks like the following:

To do that we could take each discrete section of the main method above and extract it to a method that describes exactly what it does:

class Main {
  public static void main(String[] args) {
    printNumbersOneToTen();
    printNumbersElevenToTwenty();
    printNumberTwentyOne();
    printNumbersTwentyTwoToThirtyFive();
  }

  private static void printNumbersOneToTen() {
    for(int i=1; i < 11; i++) {
      System.out.println(i);
    }
  }

  private static void printNumbersElevenToTwenty() {
    for(int i=11; i < 21; i++) {
      System.out.println(i);
    }
  }

  private static void printNumberTwentyOne() {
    System.out.println(21);
  }

  private static void printNumbersTwentyTwoToThirtyFive() {
    if(22 % 11 == 0) {
      System.out.println(22);

      for(int i=23; i < 31; i++) {
        System.out.println(i);

        if(i == 30) {
            for(int j=31; j < 36; j++) {
            System.out.println(j);
          }
        }
      }
    }
  }
}

Now the main method is working at a single level of abstraction. All of the details about how the numbers 1 to 35 are printed are hidden away behind methods that clearly express their intent. We can now quickly get a high-level view of what the main method does in 4 lines of code. If another maintainer wants to drill into the details they can at their own leisure.

To go one step further “main” isn’t a very descriptive name for what this little collection of methods does. We could extract another named method that is more descriptive. We can introduce another abstraction.

public static void main(String[] args) {
  printNumbersOneToThirtyFive();
}

private static void printNumbersOneToThirtyFive() {
  printNumbersOneToTen();
  printNumbersElevenToTwenty();
  printNumberTwentyOne();
  printNumbersTwentyTwoToThirtyFive();   
}

Legacy Code Example

Let’s look at an example that may be more reminiscent of legacy code you might see in production. Below is a Customer class that can calculate some discount on purchases based on a few different criteria.

The criteria are:

  • How long they’ve been a customer
  • The total amount they’ve spent with the company
  • How many other customers they’ve referred over time

The discount method below is at one level of abstraction. There isn’t a mixture of low-level details and method calls, it’s all low-level details. However, while it is at one level of abstraction it isn’t immediately clear what it does at first glance.

class Customer {
    private final LocalDate dateJoined;
    private final OrderHistory orderHistory;
    private final int referralsMade;

    Customer(LocalDate dateJoined) {
        this(dateJoined, new OrderHistory(new ArrayList<>()));
    }

    Customer(LocalDate dateJoined, OrderHistory orderHistory) {
        this(dateJoined, orderHistory, 0);
    }

    Customer(LocalDate dateJoined, OrderHistory orderHistory, int referralsMade) {
        this.dateJoined = dateJoined;
        this.orderHistory = orderHistory;
        this.referralsMade = referralsMade;
    }

    Discount discount() {
        Discount discountAwarded = new Discount(0);

        if(ChronoUnit.YEARS.between(dateJoined, LocalDate.now()) >= 10) {
            discountAwarded = discountAwarded.add(new Discount(0.40));
        }
        else if(ChronoUnit.YEARS.between(dateJoined, LocalDate.now()) >= 5) {
            discountAwarded = discountAwarded.add(new Discount(0.30));
        }
        else if(ChronoUnit.YEARS.between(dateJoined, LocalDate.now()) >= 2) {
            discountAwarded = discountAwarded.add(new Discount(0.25));
        }

        Money totalOrderValue = orderHistory.totalOrderValue();
        if(totalOrderValue.greaterThanOrEqualTo(new Money(1000))) {
            discountAwarded = discountAwarded.add(new Discount(0.05));
        }

        if(referralsMade >= 5) {
            discountAwarded = discountAwarded.add(new Discount(0.05));
        }

        return discountAwarded;
    }
}

In order to make this discount method more expressive and self-documenting, we can extract methods with clear names. To begin refactoring we extract the calculation for years spent with the company to a method called loyaltyDiscount.

However, because of this refactoring, the discount method is operating at two levels of abstraction. Loyalty discount calculation is hidden behind a method, yet the other calculations that follow are still very detailed:

class Customer {
    private final LocalDate dateJoined;
    private final OrderHistory orderHistory;
    private final int referralsMade;

    Customer(LocalDate dateJoined) {
        this(dateJoined, new OrderHistory(new ArrayList<>()));
    }

    Customer(LocalDate dateJoined, OrderHistory orderHistory) {
        this(dateJoined, orderHistory, 0);
    }

    Customer(LocalDate dateJoined, OrderHistory orderHistory, int referralsMade) {
        this.dateJoined = dateJoined;
        this.orderHistory = orderHistory;
        this.referralsMade = referralsMade;
    }

    Discount discount() {
        Discount discountAwarded = new Discount(0);

        // knowledge of how loyaltyDiscount is calculated hidden in a separate method 
        discountAwarded = discountAwarded.add(loyaltyDiscount());

        // knowledge of how to award customers who've spent a lot lives in discount() 
        Money totalOrderValue = orderHistory.totalOrderValue();
        if(totalOrderValue.greaterThanOrEqualTo(new Money(1000))) {
            discountAwarded = discountAwarded.add(new Discount(0.05));
        }

        // and also, how to award for a high number of referrals lives in discount()
        if(referralsMade >= 5) {
            discountAwarded = discountAwarded.add(new Discount(0.05));
        }

        return discountAwarded;
    }

    private Discount loyaltyDiscount() {
        if(ChronoUnit.YEARS.between(dateJoined, LocalDate.now()) >= 10) {
            return new Discount(0.40);
        }
        else if(ChronoUnit.YEARS.between(dateJoined, LocalDate.now()) >= 5) {
            return new Discount(0.30);
        }
        else if(ChronoUnit.YEARS.between(dateJoined, LocalDate.now()) >= 2) {
            return new Discount(0.25);
        }
        return new Discount(0);
    }
}

We’re back in the following place where we have a mixture of a method invocations and lower-level details:

 

Composed Method

Below we have the discount method again but at the same level of abstraction. It’s composed of small, well-named methods that both clearly express their intent and have a single responsibility:

class Customer {
    private final LocalDate dateJoined;
    private final OrderHistory orderHistory;
    private final int referralsMade;

    Customer(LocalDate dateJoined) {
        this(dateJoined, new OrderHistory(new ArrayList<>()));
    }

    Customer(LocalDate dateJoined, OrderHistory orderHistory) {
        this(dateJoined, orderHistory, 0);
    }

    Customer(LocalDate dateJoined, OrderHistory orderHistory, int referralsMade) {
        this.dateJoined = dateJoined;
        this.orderHistory = orderHistory;
        this.referralsMade = referralsMade;
    }

    Discount discount() {
        Discount discountAwarded = new Discount(0);

        discountAwarded = discountAwarded.add(discountForNumberOfYearsWithCompany());
        discountAwarded = discountAwarded.add(discountForHighTotalHistoricalSpend());
        discountAwarded = discountAwarded.add(discountForHighNumberOfReferrals());

        return discountAwarded;
    }

    private Discount discountForHighNumberOfReferrals() {
        if(referralsMade >= 5) {
            return new Discount(0.05);
        }
        return new Discount(0);
    }

    private Discount discountForHighTotalHistoricalSpend() {
        Money totalOrderValue = orderHistory.totalOrderValue();
        if(totalOrderValue.greaterThanOrEqualTo(new Money(1000))) {
            return new Discount(0.05);
        }
        return new Discount(0);
    }

    private Discount discountForNumberOfYearsWithCompany() {
        if(ChronoUnit.YEARS.between(dateJoined, LocalDate.now()) >= 10) {
            return new Discount(0.40);
        }
        else if(ChronoUnit.YEARS.between(dateJoined, LocalDate.now()) >= 5) {
            return new Discount(0.30);
        }
        else if(ChronoUnit.YEARS.between(dateJoined, LocalDate.now()) >= 2) {
            return new Discount(0.25);
        }
        return new Discount(0);
    }
}

We could then refactor this a little further so that it’s more succinct and reads a bit more like English:

Discount discount() {
    return new Discount(0)
            .add(discountForNumberOfYearsWithCompany())
            .add(discountForHighTotalHistoricalSpend())
            .add(discountForHighNumberOfReferrals());
}

Discount is now a Composed Method. Each method has a single responsibility and their names clearly express their intent. We’re back at the following where discount is operating at a single level of abstraction:

In conclusion, keeping methods at a single level of abstraction helps improve readability. Switching between high-level abstractions and lower-level details makes code difficult to follow. The difficulty comes from the increased cognitive capacity required to follow along.

We can solve this is by decomposing long methods. Do this by extracting smaller, clearly named ones that express intent. Favour methods composed of other smaller, clearly named methods.

Happy slapping!

Buy me a coffee Buy me a coffee

😍 ☕ Thank you! 👍

Share

Leave a Reply

Your email address will not be published. Required fields are marked *

Post comment