Do utility functions break encapsulation?
It would only be appropriate to start this post with the snippet of a utility function. So, here it is.
The function getActiveAndCompletedStats
takes in a list of Task
objects and produces a statistic with the percentage of active and completed tasks. Someone declared the function inside a StatisticsUtils.kt
file as a top-level Kotlin utility function.
I have seen quite a few functions like this, and I am sure you have too. Before we go any further, take a moment to try and answer this question - How do you decide when to write a utility function?
Many of us decide to write a utility function when we cannot find a suitable place. It is a "safe" choice. If you're an object-oriented programmer and utility functions litter the codebase you work on, there could be underlying design problems such as missing objects, data clumps, primitive obsession, duplication, etc.,
Let's try and improve the example showcased above. But first, the nitpicks.
The function getActiveAndCompletedStats
takes in a single nullable parameter tasks: List<Task>?
(line 2
). However, line 4
asserts it as a non-null value with the use of tasks!!.size
. It is not related to what we are trying to achieve but is a classic example of contract violation and coupling. Functions like these lead to runtime exceptions (contract violation). They burden the callers to perform null checks before calling the function (leaking implementation detail and causing coupling). This function is easy to fix, and I am sure you know how to do it. So, moving on.
One of the things I like about this snippet of code is using a StatsResult
class to represent the statistics.
When we talk about object-oriented design, one of its fundamental principles is encapsulation. Encapsulation is grouping data and its associated behavior in one place. In this scenario, the data and the behavior are not together. How to fix it? Bring them together. Easy peasy 🤷🏽♂️
Here is one solution that uses the factory function pattern,
IMO, StatsResult
is too abstract a name, and I prefer TasksStats
. It reflects the intention of the class better. You could argue that we just moved a function from a top-level file into a companion object
. Yes, and that's a win because of the following reasons.
- No utility files/classes.
- We know how
StatsResults
andList<Tasks>
are related to one other. - We have the data and its associated behavior in one place.
- This particular use case deals with object creation; hence we use a factory function inside a
companion object
. - Improved discoverability and a nicer API.
I am not convinced.
If someone gave me that explanation, I probably wouldn't be too. But wait, there is more. One of the mistakes we often make is treating all utility functions the same. We need to draw a clear line between what can be a utility function and what cannot. There are two kinds of candidates for utility functions.
- Functions that interact with the framework, platform, or external libraries.
- Functions that deal with your business domain.
For example, good candidates for utility functions could be all the math you do to make things easier while working with the canvas API, setting a translucent background for your window, changing your app's theme from dark to light mode, etc.,
These are good candidates for utility functions because they address specific needs in your application, and also, we don't have access to their source code (you know what I mean).
On the other hand, functions that deal with your business domain like the one shown above are not good candidates for utility functions. Functions that fall into this category are collateral damage due to unfamiliar problem domains or poorly expressed domain ideas. As you become more familiar with your domain, try encapsulating these functions with the data they operate on. Often, you may have to create new classes that express those domain ideas with more clarity.
Language capabilities
Languages influence the syntax of our utility functions. In languages like Java, we end up writing classes with static functions. Therefore, a utility function call may look like this - ButtonUtils.asPrimary(myButton)
. In an object-oriented language, this syntax may stick out like a sore thumb.
However, languages with extension functions like Kotlin enable developers to write utility functions with a syntax that may seem more natural to object-oriented programmers. For instance, myButton.asPrimary()
. But it is faux encapsulation and is easy to abuse. If you have access to the class and the source, why not make it a member function instead of an extension function?
One exception to this idea is working with a class shared across different bounded contexts. A function may be of use in a context but not in others. In such cases, you can write an extension function that serves your cause and is not available to other contexts.
What about libraries with utility functions?
The Java standard library and the Kotlin standard libraries have their fair share of utility functions. I wouldn't be surprised if your framework or platform of choice has them too. However, as developers, we have to understand the context behind these choices. We build applications to cater to a specific problem domain; frameworks and platforms are more general purpose. The design choices framework and library developers make may not be relevant to us and vice-versa.
One of our responsibilities is to question the context behind these design choices and critically draw conclusions relevant to our needs. Utility functions for business domains are not the best idea for application developers.
What if my codebase has a lot of utility functions?
That means you have a treasure trove of knowledge buried under these functions. If you take a closer look at these functions, you will spot common patterns among them. A good place to start is to look at their function parameters and their usages. Eventually, you will unearth hidden objects. And when you find some, create a new class or move the behavior to an existing class. You can also use the @Deprecated
annotation to educate your team about your discovery. Start small, refactor incrementally to reach a state where you have fewer utility functions.
Sometimes, you may not have understood a domain entirely, and it's okay to leave some functions behind. Over time as you acquire more knowledge, the number of utility functions will become negligible. I don't guarantee zero utility functions, but instead of numerous utility functions, you'll have a rich set of business domain objects.
In case if your utility functions are due to data clumps, you may find this article helpful.
Do utility functions break encapsulation?
Yes, and no. But, now you know why! 😉
If you enjoyed this article, follow me on Twitter for more content like this.