Object-Oriented Programming: It’s more than “classes and stuff”

There are a lot of wildly misunderstood concepts in programming. Some that spring to mind are agile, unit testing and, yes, object-oriented programming. Let's talk about that.

Some history

The language credited to be the first "true" OO language is Smalltalk, which Alan Kay created. The idea of OO had been floating around, though the terminology was first pushed to the mainstream by Alan Kay and Smalltalk, and Alan Kay has been credited with coming up with "Object-oriented".

What tends to happen in our industry is that there's a new concept hitting the scene, and then people go wild for it and completely miss the point. We saw it happen with Agile, where most usages of the word have little to nothing to do with the actual values of Agile. It also happened to TDD and unit testing. Don't get me started on what we see happening to microservices... And you probably guessed: It happened to OOP as well.

In a post regarding Smalltalk, Alan Kay wrote the following (Paraphrased for emphasis):

Just a gentle reminder that I took some pains at the last OOPSLA to try to remind everyone that Smalltalk is not only NOT its syntax or the class library, it is not even about classes. I'm sorry that I long ago coined the term "objects" for this topic because it gets many people to focus on the lesser idea.

He then proceeded to write the following:

The big idea is "messaging"

I talk to many developers, and very few think of "messaging" as a core part of OOP - yet it originally was a central concept of OOP.

Heck, subclassing and inheritance weren't even part of the initial versions of Smalltalk and, therefore, not a core feature of OOP at the time. If we google "OOP concepts", we find an endless list that includes inheritance and polymorphism!

I really like Kay's views on OOP, but it is clear that he has lost the "definition war". Today, OOP is defined as something very different from its roots. That doesn't mean Kay's version is invalid, but I hope we can find a different and more appropriate label, like "Message-Driven Development" or something that expresses that big idea. I want that idea to be a part of the developer's vocabulary, like we have functional and object-oriented programming.

The main point here is that we, as developers, tend to take something and twist it into something else, which we will see more of.

Bad, But Common OOP

Some developers shut down whenever I speak about OOP practices. After all, is writing OO the goal? No, it isn't! The goal is to write working code that delivers value to the business so that it can continue to be profitable and we can continue to have a job! In that sense, whether something is OO doesn't matter!

If you share the sentiment above, then I got you, bud! But you forgot one thing: We must also write systems that can evolve and deal with the complexities of new requirements while also being maintainable. It doesn't help that the system does the right thing now if the codebase is so rotten that it cannot be reliably kept alive in production or evolved to compete.

So, the goal isn't to do OOP but to make good systems. If we use OO languages like Java or C# and don't write in an OO style, we most often end up with a worse codebase. It is okay not to do OO, but then we should use the tools (languages) that are optimised for the chosen development style. If you don't want to write OO, don't use an OO language.

I see developers use OO languages but refuse to write the code in the OO style. Even worse, many do not know what OOP is beyond "well, there are some classes and stuff".

Let's look at a typical example:

public class AccountService {
    private AccountRepository accountRepository;
    private TransactionService transactionService;

    public AccountService(AccountRepository accountRepository, TransactionService transactionService) {
        this.accountRepository = accountRepository;
        this.transactionService = transactionService;
    }

    public void transferFunds(int sourceAccountId, int targetAccountId, double amount) throws InsufficientFundsException {
        Account sourceAccount = accountRepository.getById(sourceAccountId);
        Account targetAccount = accountRepository.getById(targetAccountId);

        if (sourceAccount.getBalance() < amount) {
            throw new InsufficientFundsException();
        }

        transactionService.createTransaction(sourceAccountId, targetAccountId, amount);
        sourceAccount.setBalance(sourceAccount.getBalance() - amount);         
        targetAccount.setBalance(targetAccount.getBalance() + amount);
        accountRepository.update(sourceAccount, targetAccount);
    }
}

The code above will look fine to many developers reading this, but there are some real issues here.

For example, suppose the fund check is outside of the Account class. In that case, this indicates there are few protections against amount changes within the class itself - and developers will have to remember to do this check in every scenario where an account's balance needs to be changed.

Another issue is that an account's balance is not tied to a transaction. In the example above, we will also end up with a transaction. Still, nothing stops a developer from making a mistake, altering the account's balance without adding a transaction - or vice versa.

The argument against transaction scripts is not that they're not OO, nor whether they work. Of course, they work, but that's not the point! "Working" is, and has always been a poor argument. Transaction scripts put a lot of burden on the developers maintaining that codebase. If we look at the code above, we see that traps are introduced in this short piece of code - imagine the traps a system built like this might contain!

The main issue is complexity - or where we put that complexity. The above code looks simple enough: It gets the accounts, updates the balances, creates a transaction and persists it. The entire thing is very straightforward. Some argue that is a strength, which it is! But there's a tradeoff. Writing transaction scripts is an architectural decision (even if it wasn't a conscious decision), and all architectural decisions come with tradeoffs. Transaction scripts deal with local complexity well but suffer when it comes to code reuse and managing complexity across a larger codebase.

In other words, A transaction script might look fine when you look at any given method, but it becomes a mess if you look at the system as a whole.

Good, But Rare OOP

If transaction scripts are bad, what is good? Assuming we're using an OOP language, then OOP is pretty good! Let's take a look at an example:

public class Account {  

    private final double accountId;  
    private double balance;  

    private Set<Transaction> transactions;  

    protected Account(double accountId, double balance) {  
        this.accountId = accountId;  
        this.balance = balance;  
    }  

    protected void withdraw(double amount) throws InsufficientFundsException {  
        if (balance < amount) {  
            throw new InsufficientFundsException();  
        }  
        this.balance -= amount;  
        transactions.add(new Transaction(accountId, amount));  
    }  

    protected void deposit(double amount) {  
        this.balance += amount;  
        transactions.add(new Transaction(accountId, amount));  
    }  
}

There's a bunch of changes from the original code. For one, there's no longer a AccountService, and more responsibility is put on the domain object Account. Why? Because of encapsulation!

What does Alan Kay's version of OOP and this version of OOP share? They both value encapsulation, where data is protected and only accessible through methods. It is the most essential element of OOP. If you don't combine functionality and data, then you're not doing OOP - which we saw happen with the transaction script example.

If we look at the properties within the Account class, we find zero getters and setters. Some argue that getters and setters are evil. Regardless of what you think about getters and setters, if we adhere to the Tell-Don't-Ask Principle, we will end up with a system where getters and setters are mostly obsolete.

Looking at the example, we see that our data is protected, and we're protected against misuse. We cannot do a withdraw or deposit without a Transaction being created. Here, we see how objects should go together to enforce business rules. The transaction script version pushes that responsibility to some other service, but that service lives independently from the functionality that does the transfer, so we don't get the same safety.

The keen-eyed of you will have realised that the code above doesn't implement the transfer functionality, so let's take a look at that:

public class TransferFeature {  

    private final AccountRepository accountRepository;  

    public TransferFeature(AccountRepository accountRepository) {  
        this.accountRepository = accountRepository;  
    }  

    public void transferFunds(int sourceAccountId, int targetAccountId, double amount) throws InsufficientFundsException {  
        var sourceAccount = accountRepository.find(sourceAccountId);  
        var targetAccount = accountRepository.find(targetAccountId);  
        sourceAccount.withdraw(amount);  
        targetAccount.deposit(amount);  
        accountRepository.update(sourceAccount, targetAccount);  
    }  
}

Pay close attention to access modifiers so far. The Account class has most things as protected, meaning only code within the package can access it. The only public method is transferFunds. In other words, we're using access modifiers to protect our data and tightly control how that data can be modified.

NOTE
The code above takes some inspiration from DDD and Clean Architecture. One can implement the above in a million different ways. The goal isn't for the code above to be the only "correct" answer. The goal is to provide an example illustrating how we can use objects to encapsulate data - to protect our data.

If we write systems this way, we end up with a safer codebase that is harder to misuse. There are fewer opportunities for bugs as the system evolves, as our data is protected.

We also end up with systems that are easier to test. Look at the Account class and its methods: They're super easy to write a test for! No mocking required! Some might argue that mocking is still needed for the TransferFeature, but now we're getting into testing theory and the fact that unit tests are also a concept wildly misunderstood. Here's a video that goes into some detail, but it is out of scope for this post.

The code example in this post is just an example; there's a lot to it. We really see the power to build up a system where the domain enforces its rules once we start thinking about domain models, value classes, etc. A well-designed domain cannot be put into a permanent* inconsistent state, and through that goal, we reduce bugs and increase code reusability.

*"Permanent" because we might have eventual consistency, but that is also outside the scope of this post.

Object-Oriented Programming Is Primarily About Encapsulation

To write OO-style code, you must actively engage with encapsulation. Suppose you leave everything publicly accessible where data isn't protected. If all properties are publicly available and all classes are available to the entire codebase, then you're not writing in an OO style. It's as simple as that.

I describe OOP as the combination of data, access and functionality. We put our data in an object and control the access to that data with methods. That is the core idea of writing code in an OOP style. Taking that core idea and making it usable, we also need other ideas like polymorphism and inheritance, but those are supporting ideas. I can have an application full of inheritance, but that wouldn't yield many of the benefits of OOP. The benefits of OOP are derived from encapsulating data and protecting access to that data.

Some will disagree with my take on what OOP is, and they will argue that it also includes polymorphism, inheritance, etc. That argument is missing the point. If we have a system with classes but no encapsulation, then what is the point of the classes? Other programming paradigms have ways of reusing functionality, so if we're not going to write code in an OO style when using an OO language, we may pick a language suitable for the paradigm we want to use. Some might not want to "pick a lane", which is fine but risky. One might hope for "getting the best of both worlds" when mixing two paradigms, but I see the "worst of both worlds" whenever I encounter this.

Why People Don't Write In An Object-Oriented Style When Using An Object-Oriented Language

The answer is simple: Lack of education. Formal computer science education tends to do a poor job of educating people, and we're not doing much better as an industry. It took me years to discover the above - because no one told me. Presumably, because they didn't understand it themselves. As an industry, we're really good at sharing information - there are sources everywhere. Especially online. Yet, we do such a poor job of putting all that information into practice.

What happens when we push an OO language on someone who doesn't actually know what OO is? You get transaction scripts, anaemic domain models, and 3-layered architectures—the trifecta of poor system design. The reason we end up with these is because they're the easiest things to start with. It is easy to work with classes if their data is publicly available. It is easy to write logic if you don't have to consider designing a system. Most of all, it is easy to reason about an architecture split up into logical layers rather than features or domains. These are, conceptually, the most basic architectural pieces one can make, and people tend to make them because they are simple. They are, in fact, so simple that people with zero system design experience tend to rediscover them. This is why we see them crop up again and again.

We don't see proper OO, Domain-driven design and Onion/ports-adapters architectures because they require some knowledge to get going. They are more complex ideas, and it is unlikely that people lacking education will be re-inventing these. And this is not to put people down. I lacked education for an embarrassingly large part of my career. Not too many years ago, I waved the banner at a meeting where I exclaimed that entity classes shouldn't have logic and should be treated more like data structures. At the time, OOP was just code... but with classes. At the time, I was wrong.

Once we move away from the most straightforward approaches, we also get a tradeoff in complexity. Things that used to look simple now look a little more complex - and many developers are caught off guard by that. Simplicity is a good thing when it comes to code. What a lot of developers fail to distinguish between is local complexity and solution complexity.

Local complexity is the complexity of a given method or a class, where we often focus on a single feature. From this point of view, I face the most pushback when introducing more advanced ideas because they tend to introduce more local complexity. If we look at the "good OO" example, we see this happening. Rather than having everything needed in two service classes, we might end up with logic spread over many classes. We also might end up with more classes overall, which again might have a more complex relationship between them. This will seem overly complicated if we look at local complexity and nothing else.

The problem of looking at local complexity is that it ignores the overall health of the codebase. The introduced complexity helps the codebase manage more complexity as a whole. Most developers have seen systems with a tangled mess of service classes, proving that the architecture doesn't handle complexity very well. But if we introduce the right kind of abstractions at the right places, we will be able to manage that complexity just fine.

Hot tip: If you're in a codebase and feel it is "too large". Consider what that says about the architecture used. Why can't that architecture handle the complexity of the codebase?

Suppose we accept that part of a developer's responsibility is to write code that can evolve and grow as the system changes throughout its lifetime. In that case, we must also consider how we will do that. If we go with transaction scripts, we introduce a natural limitation for that codebase. If the idea is to bypass that problem by creating a bunch of services ("microservices"), then you have introduced massive complexity. Or you can build the codebase to manage most of that complexity.

NOTE
I am not advocating against distributed computing. Most projects and companies should start with a modular monolith and only split up when/if necessary. If we use transaction scripts, we create an architectural limit to the complexity a codebase can manage. In the former solution, we split because it is appropriate and natural; in the latter, we do it because of architectural pains.

Good OOP != Good system

Throughout this post, I've highlighted that OOP leads to maintainable systems - at least regarding OO languages. That is a half-truth. Encapsulation leads to healthier codebases, but it isn't the be-all-and-end-all of things to consider.

It would be reckless of me to say, "Use encapsulation, and you have a good system" because that isn't true. There are a lot of factors at play. For a good system to be "good", we need a well-thought-out design where we manage coupling and direction of dependencies within that system. This requires design skill, resulting in architectural decisions that value simplicity and straightforwardness over the actual maintainability of the overall system.

So many pieces go into creating a healthy and sustainable codebase, and proper OO is just one of the ingredients that can aid in achieving that goal.

Wrapping up

In this post, I've outlined one of the major issues I see with how people use OOP today - that they don't use it. They use objects but don't use them to get any of the benefits they bring. This results in confused codebases being split into separate services out of necessity since the chosen architecture can't manage complexity.

It is fine if you don't want to do OOP. As I initially stated, the goal is not to do OOP but to create maintainable, evolving systems. However, if you write in an OO language and don't want to write in an OO style, at least ensure you have educated experiences about the architectural decisions you make. Avoid falling into what seems most convenient. Pick something and know how it'll scale with the application.

Think design and consider where complexity is put. Consider both local and solution complexity, and avoid focusing on just one. Consider what makes the most sense for the trajectory of a given solution. Will it grow? Will it largely just run without a lot of changes? Will it need deeply nested and complicated logic? Is it likely that it will?

In other words, consider the complexity that the codebase will require and build for that first. Evolve the architecture as you go along and, if needed, pivot to something that can deal with the complexity if it outgrows the system.

Credit

I want to credit Eric Elliot on his post "The forgotten history of OOP", which pointed me to some valuable sources used in this post.

Previous
Previous

Interface hell: The Interface Segregation Principle doesn't mean “Add interfaces everywhere”

Next
Next

Anemic Domain Models