Pure vs Impure Methods: Understanding Side Effects in Coding

What’s the difference between these two methods?

public int AddPure(int a, int b)
{
return a + b;
}

and

public int AddImpure(int a, int b)
{
var c = a + b;
logger.Log($"Added two numbers: {a} and {b}, with the result {c}")
return c;
}

Well, there are two ways of answering this question (aren’t there always?).

One way is to look from outside in. If we squint our eyes and only look at the method signatures, they are identical. (The only difference is the name, but that’s just so I can reference them later.) They take two numbers as inputs, and return another number.

The other way is to look from inside out. By applying the Single Responsibility Principle, it is obvious that AddImpure does an extra thing. Besides computing the result, it also writes into a log. And depending on your past experiences, you might view this as a necessary evil. Perhaps even the only way to achieve good quality, robust and solid (pun intended) code.

I would like to present you with some alternatives. Not better ways to write code, not the only way to write code, but alternatives. Which may prove to come in handy in certain situations.

OK, before handing you the answer, let’s analyze the two methods from the perspective of tests.

To test the AddPure method, you only need to test that the method performs addition correctly.

To test the AddImpure method, you need to check that the addition is performed correctly, and also check that the method outputs the correct log message. Methods like these are said to have side-effects. Meaning they have an impact on the “system” outside of just computing and returning the results.

There are two ways of checking the log message. One way is to actually log the message somewhere, and inside the test, verify that the message is actually there. The other way is to mock the logger dependency and only verify that AddImpure called the Log method with the right arguments. Both of these strategies of testing have their drawbacks.

On one hand, actually depending at test time on an actual storage mechanism for logs, be it the file system, or a database, makes the tests more brittle and more error prone. The file system could be read-only (or inaccessible for whatever reason), or the database connection could fail and become unreachable. None of these are the actual reasons we are testing the method in the first place. They only add complexity to an already complex problem (hey, adding numbers is hard, ask any preschooler)

On the other hand, mocking the logger dependency and testing the Log method was called on the mock is a leaky abstraction. The tests are not supposed to be coupled to implementation details. What if tomorrow, the programmer finds a better way of logging, not necessarily by calling the Log method. Then the tests would fail, not because of the way the addition is performed, but because an implementation detail changed. And yet the log message would still arrive at it’s intended final destination, but in a different way, and the result of the addition will not change, math does not change because we decide to log or not. Does that detail matter to your “addition” tests? I would argue not.

One way of converting an impure method into a pure one (at least in this case) is to make the message to be logged part of the return type or data structure.

public (int, string) AddWithLog(int a, int b)
{
var c = a + b;
return (c, $"Added two numbers: {a} and {b}, with the result {c}");
}

What happened here is that we inverted the responsibility stack: instead of making our adding method responsible also with logging, we defer the handling of the message to the caller. Now the caller will be in control (and responsible) of handling both the result of the operation (the addition) and also route the message to a proper handler or sink.

This philosophical shift in thinking about responsibilities made our lives easier in the short term, but it also opens the door to other helpful patterns, as we’ll see later.

This neat little party trick helped us turn an impure method into a pure one, whose tests now need only test the relationship between inputs and outputs, no side-effects included. This simplifies the problem greatly for our little method, but makes life a little harder for the caller, but there are ways to mitigate that as well.

  • Testing perspective
    • Usually pure methods are very easy to test
    • Impure methods are harder because the tests need to take into account the secret lives of the hidden dependencies
  • Dependencies perspective
    • Pure methods have no dependencies
    • Impure methods depend on injected or external dependencies – which are not always so obvious

The Moral Of The Story

When the method finally acknowledged that it was not it’s job to also log, its life became so much simpler.

So, remember: it’s OK to say “It’s not my job!”