The Single Level of Abstraction Principle (SLAP)

In this post I’m going to talk about giving methods a good slapping! 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? Let’s look at the following code that prints out the numbers 1 to 35 to console. Yes it’s awful and nonsensical but I needed a toy example for this post:

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);
    }
  }
}

Here the main method is working at different levels of abstraction. When you read the method you can see that the printing of the numbers 1 to 10 has been extracted to a method whilst the printing of the numbers 11 to 35 happens in main itself. Here an abstraction has been created for the printing of the numbers 1 to 10, it’s more abstract that the rest of the method. The concept of printing out the numbers 1 to 10 has been hidden away behind the method named printNumbersOneToTen(), and if this method is true to its name then that’s all we need to know to continue reading through main. We’ve just superficially processed all we need to know about the printing of 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 one line of code expressing the concept of printing of numbers 1 to 10 and then 27 lines of code that are a much more detailed description of how to print the numbers 11 to 35. You can image this as follows, where the green block is a method invocation and the other coloured blocks are low level details:

What would be good is if we could get the main method to a point where everything is at the same level of abstraction, where it’s a composition of method calls and 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 of 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. Now if another maintainer wants to drill into the details they can at their own leisure. To go one step further, main isn’t very descriptive of what this little collection of methods aims to achieve so we could extract another method that abstracts these guys!

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

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

 

Now let’s look at an example that may be a little more reminiscent of legacy code you see in production. The below is a Customer class that can calculate some discount on purchases based on a few different criteria, how long they’ve been a customer, the total amount they’ve spent with the company and how many other customers they’ve referred over time:

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;
    }
}

Other design issues aside the discount() method is at one level of abstraction but what it does is unclear at first glance.

The below is the same discount() method but now it’s at different levels of abstraction. The part of the method that looked like it was to do with calculating the discount awarded for how many years the customer has been with the company has been extracted to a method called loyaltyDiscount(). Because of this the discount() method is now operating at two levels of abstraction, how the loyalty discount is calculated is hidden away behind a method and the remaining details of how further discounts are added still live in discount() in all their gory detail:

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 now back in the following place where we have a mixture of a method invocation and lower level details:

 

Below we have the discount() method again but at the same level of abstraction. It’s clearer as discount() is now composed of well named methods that clearly express their intent and each 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());
}

Now discount() is composed of smaller methods with a single responsibility and names that 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 maintainers better understand their intent from a high level perspective. Having to switch between high level abstractions and lower level details in the same method incurs a cognitive tax to readers of our code. We can be nicer to our fellow maintainers by decomposing long methods into smaller methods each with a single responsibility and clear names that express their intent.

Share

Leave a Reply

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

Post comment