We’ve Been Teaching Object-Oriented Programming All Wrong (Part 1)
Whom among us hasn’t come across the classic example of
While these are all sensible ways to think about and teach the core concepts of object-oriented programming, I’ve always found them to be quite impractical and perhaps a bit too abstract (get it?) for most developers to appreciate the value of OOP.
The core principles of:
Is there a more pragmatic, practical way that we can think about object-oriented programming?
The traditional way that we think about OOP is simply too rigid. There’s a term for this called being an object bigot. Quite frankly, it’s a very impractical way of thinking about writing code and helping junior developers adopt object-oriented practices which exist on a spectrum and should be applied or layered as the problem domain demands. In other words, it’s not always necessary to build solutions in a strict and rigid object-oriented way.
But there are really practical reason why even using basic, loose object-oriented principles can help any team: discoverability and organization of responsibility.
Most visual IDE’s and editors provide some level of auto-completion and intellisense and in many cases, writing simple wrappers around groups of related functions is still a small victory and improvement.
Consider the following listing:
While this is a perfectly acceptable way of working with such utility functions, the challenge is that this approach is hard to scale from a usability perspective. As the number of such utility methods increases, it becomes increasingly difficult to even find the related functions when you’re working in another part of the codebase and therefore very common to have either copy-pasted snippets or — even worse — facsimiles floating around.
If we simply loosely “encapsulate” this with a wrapper, our result is the following:
Of course, it doesn’t have to use a
class designation is just syntactic sugar). But doing this simple wrapping has a really powerful benefit: ease of discovery.
Once I have the functions “encapsulated” with a container, the ease of discovery is significantly improved. Instead of having to know the n operations, we only need to know the entry point now (
Compare this with trying to access the purely functional version:
But there’s another benefit in that we can associate some parts of our code with the scope of the class instead:
And facilitate reuse if we wanted to use that
pattern somewhere else in our class. Of course, this is also possible with function closures or hoisting the variable up to the module scope, but this encapsulation helps with our organization of logic without polluting our symbol space for intellisense.
Avoiding Divergent Behavior
You’ll note that I used the term “encapsulation” in quotes above. This is because we’re really just scratching the surface.
While there are very academic ways of thinking about what proper encapsulation is, I like to think about more practically: it’s a way of avoiding divergent behavior.
This is a particularly common problem when a team has fallen into what Martin Fowler terms as an Anemic Domain Model.
The basic symptom of an Anemic Domain Model is that at first blush it looks like the real thing. There are objects, many named after the nouns in the domain space, and these objects are connected with the rich relationships and structure that true domain models have. The catch comes when you look at the behavior, and you realize that there is hardly any behavior on these objects, making them little more than bags of getters and setters.
Fowler’s Patterns of Enterprise Architecture describes a related pattern known as Transaction Script. Both Anemic Domain Model and Transaction Script are common in TypeScript because of how easy it is to simply use
The fundamental downfall of this approach to structuring logic in even a medium sized system worked on by multiple developers is that there will be duplication of code and more than one way of working with an object. Which one is the right one? One approach might be needed for one transaction. One approach is used in another transaction. But which is the right one when constructing a new transaction?
Let’s look at a simple example:
Note that in this example, the validation of the
User entity exists separate and apart from the instance itself. In other words, if we want to validate the user, we have to know that such a transaction already exists somewhere else.
In psychology, there is a term called the locus of control and I like to think of this approach as assigning an external locus of control rather than an internal locus of control. When the entity has an external locus of control, it can be difficult to predict how the entity will be mutated in different code paths.
This can be particularly problematic when the format of the data is important. Consider another example:
We can improve the ergonomics and usability of our types by simply taking advantage of encapsulation:
isUserValid is now located within the entity itself and much easier to discover for a new developer joining your team; you don’t have to worry about finding the right import or even know that there is a function to import. Additionally, when changing the logic for validating users, it becomes far more likely that there is only one place that needs to be updated.
What about our other use case?
With this solution, we can take advantage of the constructor to normalize the data associated with the
Store object. More importantly, we have a context to which we can attach additional behaviors and make them more discoverable for the other developers that are working alongside us or have to consume our API or SDK.
In other words, one important advantage of object-oriented code is that it helps to standardize how we interact with the data and objects in our domain space — the very essence of Domain Driven Design.
In the next part of this article, we’ll dive in and explore — what is in my opinion — the real power of object-oriented programming: the ability to use structural constructs in place of procedural constructs (part 2).