We’ve Been Teaching Object-Oriented Programming All Wrong (Part 1)

Charles Chen
6 min readApr 2, 2022

--

Whom among us hasn’t come across the classic example of Shape, Circle, and Square? Or Animal, Mammal, Human, and Cat?

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:

  • Encapsulation
  • Abstraction
  • Inheritance
  • Polymorphism

Are great and all, but seem a bit too academic and difficult to connect to the reason why one would want to write object-oriented code in the first place. After all, in languages such as JavaScript and TypeScript, there is a spectrum of styles that range from highly functional to very much object-oriented. Why should a developer or team choose one style over another?

Is there a more pragmatic, practical way that we can think about object-oriented programming?

Discoverability

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:

Two utility functions for working with strings in a utils.ts file.

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:

A very loose “encapsulation” of the functions with a wrapper class that does nothing else aside from providing a context.

Of course, it doesn’t have to use a class construct (and in fact, in JavaScript, the class designation is just syntactic sugar). But doing this simple wrapping has a really powerful benefit: ease of discovery.

Note how compact the auto-completion list is once I have the object instance.

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 (StringUtils).

Compare this with trying to access the purely functional version:

The usage of a purely functional approach makes it hard to find what you need.

But there’s another benefit in that we can associate some parts of our code with the scope of the class instead:

We can reuse this pattern within the scope of the class.

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 interface or type.

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:

A very typical pattern to see in TypeScript.

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.

Courtesy of CeDAR: Center for Dependency, Addiction and Rehabilitation. With Transaction Scripts, the end result is often an anemic domain model where the specific transaction modifies the state of the entity — possibly in a different way than another transaction. This is an external locus of control as the entity’s state is mutated by external interactions.

This can be particularly problematic when the format of the data is important. Consider another example:

Because we’re using our objects a simple maps, we would need to externalize the logic for validating the formatting of the hours to make sure it’s set correctly.

While this seems obviously bad, I see this time and again because we are doing a terrible job of teaching object-oriented principles. These examples use TypeScript — because I find these issues particularly egregious with JavaScript and TypeScript — but are common even in Java and C# codebases where developers use POJO’s and POCO’s.

We can improve the ergonomics and usability of our types by simply taking advantage of encapsulation:

Note how the check for a valid user is now owned by the object itself.

The behavior 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?

We use the constructor to manage normalization of the data (and add more data validation!). This can be a very powerful pattern when working with document oriented databases to help isolate schema changes by encapsulating the data normalization behavior.

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.

While much noise has been made about the rise of functional programming, alongside the rise of JavaScript, I find that object-oriented principles — even adopting them at a very basic level — can help progressively improve codebases and make them more approachable, cleaner, and easier to work with especially when building large systems in a team context.

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).

--

--

Charles Chen
Charles Chen

Written by Charles Chen

Maker of software ▪ Cofounder @ Turas.app ▪ Maker of CodeRev.app ▪ GCP, AWS, Fullstack, AI, Postgres, NoSQL, JS/TS, React, Vue, Node, and C#/.NET