We’ve all worked on apps and services that weren’t as clearly written as they could have been. While we try our best, the combination of complexity and time constraints can result in code that needs a bit of time to get into before making modifications.
One way of reducing complexity – even if only to save ourselves a bit of head scratching when returning to our own work a few days later – is to follow the Single Responsibility Principle. If we find we later need to redefine the meaning of 'single', or if code was written without the SRP in mind, it’s still possible to apply it; we identify multiple responsibilities within an entity (this could be a module, class, or method), and refactor them into their own units. When doing this, we have two choices:
Move logic into a new class we can instantiate (and inject as a service).
Extract it into a static 'helper' class.
While these approaches may appear similar, there are differences that may make one choice a better (or worse) fit.
Going Head-to-Head
Before diving in, let’s first refresh our memories on the concept of a pure function. This style of method doesn’t use or modify state variables. Its outputs are entirely dependent on its inputs, and providing the same set of arguments will always lead to the same result.
Another way of looking at it is that we should be able to replace a pure function with a value-lookup table and have everything continue to work.
Testability of Extracted Functions
In terms of testing the extracted logic in isolation, there typically isn’t much difference between static and instantiable classes. In both cases, we’d need to setup our tests in similar ways. With pure functions, the main difference would be how we invoke the methods to be tested, i.e. whether we need a class instance.
The exception to this would be where state is involved. In this case, we’d need to remember to reset our classes between tests, and also be mindful about running them in parallel. This is easier with instantiable classes because we can create a new instance per test – something that isn’t possible with a static class.
Coupling and Testability of Calling Classes
The main advantage of a static class lies in its simplicity. Once created, its (public) methods immediately become available for use anywhere within a codebase. There’s no need to consider creating additional interfaces, no need to tweak IoC framework modules, and no need to modify the constructor parameters of any classes. For this reason, a static class can be a good choice for self-contained helper methods that form part of an importable class library, possibly via NuGet.
However, this simplicity is also a double-edged sword. A static class will help separate code in source form, and it’ll help in following the DRY principle. But it doesn’t reduce coupling between responsibilities.
Whether this is a problem depends on the context. A good example of where using a static class makes sense is System.Math
. The functions it contains return values that are widely accepted to be correct. There’s no need to set them up with input/output value pairs and returning anything other than what they do typically isn’t useful, not even for testing purposes.
Another example where this might not be a problem is a library that converts between different colour formats/models. After all, mocking a service to transform red rgb(255, 0, 0)
into the hex code for blue #00f
probably isn’t useful.
Let’s contrast this with logic for calculating a password hash for example. While this too should produce consistent results, there is value in mocking its outputs in tests. Imagine writing a test that verifies a method:
Takes a password string.
Hashes the password into a long hash value.
Compares it against a known value in the test assertion.
If a real hashing algorithm is used in step (2), it could unnecessarily increase the time it takes for the test to run. More importantly, it reduces its focus. Instead of only checking that the process is followed, we’d also be incorporating knowledge about the output hash. (While testing the password transformation is important, it can – and should – be done in a separate test.) Lastly, if we ever changed the hashing algorithm, we’d need to update this test.
Summary
When refactoring code into separate classes, you can often choose between creating new static or instantiable classes. The best choice often depends on context.
When you create a static class, you’ll build a solution that’s easy to use and requires minimal changes (if any) to integrate. These are often a good choice for standalone logic where functions have widely accepted expectations or conversion results.
By creating an instantiable class, you’ll allow for more flexibility by decoupling separate concepts. While needing a bit more effort to incorporate, you’ll find it can be easier to write tests for and run in parallel. As a bonus, you might be able to avoid modifying your tests if you ever need to change your implementations.
Thanks for reading!
This article is from my newsletter. If you found it useful, please consider subscribing. You’ll get more articles like this delivered straight to your inbox (once per week), plus bonus developer tips too!
Top comments (0)