Introduction
The term Object-Oriented Programming (OOP) is so ubiquitous in modern software development that it has become a buzzword, appearing on every software engineer's résumé by default with little consideration for what it means to be good at it.
So what does it mean to be good at it?
Defining "Good" OOP
Providing a general definition of OOP is relatively easy:
"Instead of a procedural list of actions, object-oriented programming is modeled around objects that interact with each other. Classes generate objects and define their structure, like a blueprint. The objects interact with each other to carry out the intent of the computer program" (Wikipedia)
By contrast, a concrete definition of what makes for good OOP is tough to capture so concisely. Good OOP is defined by a small collection of detailed design principles that includes SOLID, Cohesion and Coupling and ultimately leads to the maximum flexibility, understanding and reusability of code possible.
In this post I'll be running through a real-world-like scenario and explaining how these principles make for good OOP along the way.
A Naïve Approach to OOP
I'll use the example of an ExchangeRate object that we'll want to validate, store in and retrieve from a database. It's common for a developer who's new to object-oriented programming to define such a class somewhat like the following:
public class ExchangeRate { private const string ConnectionString = "..."; public int ID { get; set; } public string FromCurrency { get; set; } public string ToCurrency { get; set; } public double Ratio { get; set; } public void Save() { using (var conn = new SqlConnection(ConnectionString)) { // TODO: write properties to table } } public static ExchangeRate Load(int id) { using (var conn = new SqlConnection(ConnectionString)) { // TODO: Read values from table return new ExchangeRate(value1, value2, ...); } } public static bool Validate(ExchangeRate ex) { // Validate currency codes // Validate that ratio > 0 } }
The beginner designs the class this way because all of the methods and properties on the object feel like they belong together. Grouping code together based on their logical relation to the same thing like this is known as logical cohesion and while it works perfectly well at this scale, logical cohesion quickly has its downfalls.
Here's a rundown of the key problems associated with the class as it is currently designed:
- The class will become unmaintainably bloated as we add more and more functionality that relates to ExchangeRate.
- This bloat is compounded by the fact that if we later decide to allow load/save from other locations than a SQL database or to validate exchange rates differently depending on varying factors, we'll have to add more and more code to the class to do so.
- If the consumer of ExchangeRate doesn't use the SQL related methods, SQL related references are still carted around.
- We'll have to duplicate generic load/save code for every object we create beyond ExchangeRate. If there's a bug in that code, we'll have to fix it in every location too (which is why code duplication is bad news).
- We're forced into opening a separate connection for every save or load operation, when it might be beneficial to keep a single connection open for the duration of a collection of operations.
- ExchangeRate's methods can't be tested without a database because they're tightly coupled to the database implementation.
- Anything that wants to load an ExchangeRate will be tightly coupled to the static ExchangeRate.Load(...) method, meaning we'll have to manually change all those references to other load methods if we want to load from a different location at a later date. It also means that those referencers can't be tested without a database either!
Improving OOP Using SOLID
The principles of SOLID give a framework for building robust, future-proof code. The 'S' (Single Responsibility Principle) and the 'D' (Dependency Inversion Principle) are great places to start and yield the biggest benefits at this stage of development.
The dependency inversion principle can be a hard one to grasp at first but is simply that wherever our class tightly couples itself to another class using a direct reference to its Type (i.e. the new keyword or calls to static methods), we should instead find some other way of giving our class an instance of that Type referenced by it's most abstract interface that we need, thereby decoupling our class from specific implementations. This will become clearer as the example progresses.
The single responsibility principle is exactly what you'd expect it to be, that each object should have just one responsibility.
Here's all the responsibilities that the ExchangeRate class currently has:
- Hold information representing an exchange rate
- Save exchange rate information to a database
- Load exchange rate information from a database
- Create ExchangeRate instances from loaded information
- Validate exchange rate information
Since these are separate responsibilities, there should be a separate class for each.
Here's a quick pass of refactoring ExchangeRate according to these two principles:
public class ExchangeRate { public int ID { get; set; } public string FromCurrency { get; set; } public string ToCurrency { get; set; } public double Ratio { get; set; } } public class ExchangeRateSaver { public void Save(Connection conn, ExchangeRate ex) { // TODO: write properties to table } } public interface IExchangeRateFactory { ExchangeRate Create(string from, ...); } public class ExchangeRateFactory : IExchangeRateFactory { public ExchangeRate Create(string from, ...) { return new ExchangeRate(from, to, rate); } } public class ExchangeRateLoader { private readonly IExchangeRateFactory _factory; public ExchangeRateLoader(IExchangeRateFactory factory) { _factory = factory; } public ExchangeRate Load(Connection connection, int id) { // TODO: Read values from table return _factory.Create(value1, value2, value3); } } public class ExchangeRateValidator { public bool Validate(ExchangeRate ex) { // Validate currency codes // Validate that ratio > 0 } }
Code grouped in this manner is described as being functionally cohesive. Functional cohesion is considered by many to lead to the most reusable, flexible and maintainable code.
By breaking the code down into separate classes, each with a single responsibility, we have grouped the code by its functional relationships instead of its logical ones. Consuming code and tests can now swap in and out individual chunks of isolated functionality as needed, instead of carting around one monolithic, catch-all class.
Additionally, by inverting the dependencies of ExchangeRateLoader and ExchangeRateSaver, we have improved the testability of the code as well as allowing for any type of connection to be used, not just a SQL one. The benefits of dependency inversion are compounded as more and more classes become involved in a project.
What about the "OLI" in "SOLID"?
The 'O' (Open/Closed Principle) and 'L' (Liskov Substitution Principle) aren't applicable to this example as they relate to revisiting existing production code and to inheritance, respectively.
The 'I' (Interface Segregation Principle) states that no client should be forced to depend on methods it does not use and, for the most part in this example, has been covered by adhering to the Single Responsibility Principle.
If you'd like to see an example of situations when the outcome of applying the ISP and SRP differ, or an example of applying the Open/Close and Liskov substitution principles, let me know in the comments.
In Closing
Hopefully this article has begun to shed some light on how "good" object-oriented code is achieved and how it leads to more flexible, testable and future-proof code.
If you'd like for me to expand on any specific points, or cover how this becomes ever more important as the scale of a project grows, let me know in the comments and I'll do a follow up post!