A recurring question in my C# workshops and videos sounds like: "How do you know when to define a new class?"
This question is a quintessential question for most object-oriented programming languages. The answer could require a 3-day workshop or a 300 page book. I'll try to distill some of my answers to the question into this blog post.
The question for this post regularly pops up in my grade book scenario. In the scenario I create a GradeBook class to track homework grades for a fictional class of students. The GradeBook starts simple and only offers the ability to add a new grade or fetch existing grades.
Eventually we reach the point where we need to compute some statistics on the grades stored inside the grade book. The statistics include the average grade, lowest grade, and highest grade. Later in the course we use the stats to compute a letter grade. It is the statistics part where I show how to create a new class to encapsulate the statistics.
Why not just add some properties to the existing GradeBook with the statistical values? Wouldn't it be better to have the statistics computed live when the program adds a new grade?
I'm always thrilled with these questions. Asking these questions means a student is progressing beyond the opening struggles of learning to program and is no longer just trying to make something work. They've grown comfortable with the tools and have fought off a few compiler errors to gain confidence. They've internalized some of the basic language syntax and are beginning to think about how to make thing work the right way.
It’s difficult to explain how the right way is never perfectly obvious. Most of us make software design decisions based on a combination of instincts, heuristics, and with our own biases, because there is no strict formula to follow. There can be more than one right way to solve every problem, and the right way for the same problem can change depending on the setting.
There are many different types of developers, applications, and business goals. All these different contexts influence how you write code.
Some developers write code for risk averse companies where application updates are a major event, and they make slow, deliberate decisions. Other developers write code for fast moving businesses, so having something delivered by next week is of utmost importance.
Some developers write code in the inner loop of a game engine, so the code must be as fast as possible. Other developers write code protecting private data, so the code must be as secure as possible.
Some developers pride themselves on craftsmanship. The quality of the code base is as important as the quality of the application itself. Other developers pride themselves on getting stuff done. Putting software in front of a user is the only measure of success. Exitus ācta probat.
Code that is good in one context might not be as good in one of the other contexts. Blog posts and tweets about design principles and best practices often neglect context. It is easy for an author to assume all the readers work in the same context as the author, and all the readers carry the same personal values about how to construct software. Trying to avoid assumptions about the reader’s context makes posts like this more difficult to write. But, like the Smashing Pumpkins song with the lyrics ‘paperback scrawl your hidden poems’, let me try, try, try.
But first, some background.
In a language like C# we have classes. Classes allow us to use an object-oriented approach to solving problems.
The antithesis of object-oriented programming is the transaction script. In a transaction script you write code inside a function from top to bottom. Object-oriented aristocrats will frown on transaction scripts as an anti-pattern because a transaction script is a procedural way of thinking. My code does step 1, step 2, ... step n. There is little to no encapsulation happening, even when using sub-functions to break up a large function.
Transaction scripts are not always bad, though. In some contexts, transaction scripts might be an ideal solution. Transaction scripts are easy to write. Transaction scripts are also generally easy to read because all the logic is in one place. There is no indirection and no need to jump around in different class files to see everything that is happening.
The problem with transaction scripts is in maintainability. Transaction scripts are typically inflexible. There is little chance of making a change to the behavior of a program without changing, and therefore potentially breaking, an existing piece of code. And, transaction scripts can be notoriously hard to unit tests, because the script typically mixes many different operations and responsibilities into a single pile of code.
The opposite of procedural programming with an object-oriented language is domain driven design. While transaction scripts offer a simple solution for simple problems, DDD is the complex approach for complex problems.
DDD solutions typically offer high levels of encapsulation. What might be 50 lines of code in a transaction script could be 5 classes with 10 lines of code each, although you’d never need DDD for 50 lines of code, so this is a silly comparison. The amount of effort put into modeling a complex domain means the code is more difficult to write. One can also argue the number of classes involved can also make the software more difficult to read, at least when looking at larger pieces of functionality. DDD is a winner for complex problems that will have a long-life span, because maintainability is easier over the long run. The high levels of encapsulation and isolation make it harder to make mistakes, and easier to avoid unintended consequences. Each class has clear responsibilities and decouple and orchestrate well with other classes.
At this point we know there are extremes in the different approaches you can use to apply C# to a programming problem. We also know that context is important when deciding on an approach. Now back to the question.
Why do I add a class to carry the data for the statistics of a gradebook?
One reason I added the class was to promote thinking about how to approach the problem in an object-oriented manner. To me, adding the class was the right approach. The feeling of rightness is based on my personal values formed across decades of programming with C# and languages like C#. This is not a feeling you’ll have when you first start programming. But, if you are introspective and eager to improve, you’ll develop your own heuristics on the rightness of an approach over time.
My sense of rightness is strongly influenced by the single responsibility principle. SRP says a class should have a single responsibility, or from another perspective, a class should have only one reason to change. Since the gradebook handles storing and retrieving individual grades, it doesn’t make sense for the gradebook to also manage statistics. Think about the documentation for a class. If you write a sentence saying you can use the class to ___ and ___ in a system, then it might be time to look at making two classes instead of one. You can forget all other design principles and get a long way towards better software construction just focusing on SRP, and this is regardless of being object-oriented, or functional, or some other paradigm.
I also think the approach I’m showing is an approach you can use in many different contexts. The statistics are a computed result. Having a dedicated class to represent the output of a decision or a calculation is good. If the statistics were instead properties on the grade book itself, I’d have to wonder if I need to call a method to initialize the properties, or if they are always up to date, or how I could pass the results around without exposing the entire grade book to other parts of the system. Having a dedicated result for the stats gives me a collection of values at a specific point in time. I can take the stats object and pass it to the UI for display, or record the stats for historical purposes, or pass the stats to a reporting object that will email the results to a student.
In my video version of the GradeBook course, my biggest regret is not going one step further and ripping the calculations out of the GradeBook. Calculating the statistics is a unique responsibility. The documentation would say we use the GradeBook class to store grades and compute grade statistics – a clear SRP violation! The video course has a focus on learning the syntax of the C# language, and along the way I teach some OOP concepts as well. In the real world I would have a calculator class that I pass into the gradebook to make the statistics. I would certainly use a calculator class if I expect that the business will ask me to change the calculations in the future. Perhaps next year they will want to drop the bottom three grades and add a standard deviation to the set of statistics. The hardest part of software design is predicting where the future changes in the software will happen. I want to apply the single responsibility principle ruthlessly in those areas where I anticipate change and break the software into smaller bits that work together.
I can’t tell you why there must be a separate class for grade statistics. I can only say having a statistics class feels like the right approach for me. Not everyone will agree, and that’s ok. Remember the context. Someone writing high performance code will loathe the idea of more classes creating more objects. Others will say the solution needs more abstraction. Sometimes you just need to aim for the middle and avoid making obvious mistakes.
Avoid writing classes, methods, or files with too much code. How much is too much? Again, we are back to heuristics. I can tell you I’ve come across ASP.NET MVC controller actions that process an HTTP request using 500 lines of code in a single method. That’s a transaction script with too much code in a single method. The method is difficult to read and difficult to change. I see this scenario happen when a developer focuses on only getting the code to work on their machine so they can move on to the next task. You always want to look at the code you wrote a couple days later and see if it still feels comfortable. If not, the code certainly won’t feel comfortable in 6 months. Break large classes into smaller classes. Break large methods into smaller methods.
Primitive obsession is a pervasive problem in .NET code bases I review. I’ve seen currency values represented as decimals with a string chaser. I’ve seen dates passed around as strings. I’ve seen everything except a widespread effort to improve a codebase using small classes for important abstractions in the system. For example, encapsulating dates in a date-oriented piece of software.
Nothing makes software harder to support than not encapsulating those little bits of information you pass around and use all the time. I’m always amazed how a 5-line class definition can remove repetitive code, make code easier to read and support, and make it harder to do the wrong thing, like add together two values using two different currencies. The link at the beginning of this paragraph points to a post from James Shore, and the post includes one of my favorite quotes (in bold).
“... using Primitive Obsession is like being seduced by the Dark Side. "I'm only dealing with people's ages in this one method call. No need to make an (oh so lame) Age class." Next thing you know, your Death Star is getting blown up by a band of irritating yet plucky and photogenic youngsters.
Best way I know to deal with it is to get over the aversion to creating small classes. Once you have a place for a concept, it's amazing how you find opportunities to fill it up. That class will start small today, but in no time at all it'll be all grown up and asking for the keys to the family car.
Yes, get over the aversion to creating small classes! I too, once had this aversion.
I started learning OOP in the early 90s with C++. Back then, every book and magazine touted the benefits of OOP as reusability, reusability, reusability. It’s as if the only reason to use an OOP language was to make something reusable by other developers.
Looking back, nothing was more damaging to my progress in learning OOP than the idea that everything needed to be reusable. Or, that the only reason to create a new class was if I needed to put code inside for someone else to reuse. I point this out because these days you’ll still find people touting the reusability of OOP constructs, but these people mostly repeat the talking points of yesteryear and do not give any wisdom built on practical experience.
The first rule of the OOP club is to make code usable, not reusable. Reusable code is thinking about the outside world. Usable code is thinking about the inside world, where code must be readable, maintainable, and testable. Don’t make the decision on when to create a class based on the probability of some other developer on your team reusing the class or not. Do feel comfortable creating a new class definition even if the system uses the class only once in the entire code base. Yes, this means you’ll have two classes instead of one. Two files instead of one. Two editor windows instead of one. But, in many contexts, this is the right way.
It took me until around 2004 to recover from the curse of reusability...
When people ask me how to write better code, I always tell them to try unit testing. Nothing taught me more about OOP and software construction than writing tests for my own code. No books, no magazines, no mentors, no videos, no conference talks. The real lessons are the lessons learned from experience with your own code.
When I started unit testing my code in 2003 or 2004 I could see the inflexibility of my software creations. I could see the SRP violations (testing that a class could ___ and ___). I began to see how to use design patterns I had read about but never put into play, like the strategy pattern, and I could see how those patterns helped me achieve the design principles like SRP.
Fast forward to today and I am still a strong proponent of unit testing. Testing will not only help you improve the quality of the software you are building, but also the quality of the code inside. Testing will help you refactor and make changes and improvements to the code. Testing will help you ask questions about your code and find weaknesses in a design.
Testing will help you understand how to build software.
I hope I don’t make software development sound difficult. It’s not. However, improving at software development is an endurance race, not a sprint. There is no substitute for writing code in anger and doing so over a long period of time. If you care about your work, you’ll naturally learn a little bit every day, and every little bit you learn will help you form your own opinions and heuristics on how to build software.
And then, hopefully, you’ll want to learn even more.