DRY is excellent coding advice: “Don’t Repeat Yourself.” Repetition in code means if something needs to change, all that repetition may need to change with it. This advice often comes with warnings, though, that you don’t want your code to become too DRY.
Somewhere along the way, we understood that a one-liner in our code is a liability if it’s illegible. What we really want is code that is succinct.
When I was a junior developer and I started to crave foundational principles for writing better code, it was the SOLID design principles that would eventually play that role. Years later, SOLID is still at the core of my best design choices, but I found the concepts to be overly academic and unapproachable at first.
S – Single Responsibility Principle:
Every Class in your system should have exactly 1 reason to change.
O – Open/Closed Principle:
“Open for Extension, Closed for Modification”:
You should be able to extend Class behavior without modifying it.
L – Liskov Substitution Principle:
Derived classes must be substitutable for their base classes.
I – Interface Segregation Principle:
Make fine grained interfaces that are client specific.
D – Dependency Inversion Principle
Depend on abstractions, not on concretions.
Like the advice to “stay DRY”, these definitions tell us about the qualities code should have once it has become “good code”, but after studying the principles, I had a hard time envisioning how I would apply them in my daily practice. It felt as if the principles were tailored for code museum curators, not for devs. Of course, we want code that meets these criteria, but how do we cultivate it?
Asking hard questions
If you search for “SOLID design”, you’ll likely encounter plenty of articles that offer sound explanations along with before-and-after code samples, but that wasn’t terribly useful to me when I was first learning about SOLID. I’m more interested in sharing the tools behind the SOLID principles: not the principles themselves, but a set of questions that I first encountered in a presentation by Sandi Metz:
- Is it DRY?
- Does it have one responsibility?
- Does everything in it change at the same rate?
- Does it depend on things that change less often than it does?
If the answer to any one of these questions is “No,” then a refactor might improve your code’s reusability, maintainability, and legibility.
In the same way that you might apply “red, green, refactor” in an iterative fashion to test-drive a feature, these questions can be applied over and over again to refine a system.
Developing a good nose
Once your product has developed a user base, and time-to-market is no longer the most important driver for development, all that “developing fast” that you did before starts to slow you down.
If your code has become rigid, fragile, immobile or viscous, the effects can cripple your ability to effect change. These undesirable qualities are collectively known as Design Smells. These original 4 Design Smells were popularized by Martin Fowler in his book Refactoring: Improving the Design of Existing Code in 1999. Since then, others (and I have no idea who to credit here) have expanded the list to include a few additional smells worth mentioning: Repetition, Opacity & Complexity, for a total of 7:
- Rigidity: When code is hard to change
- Fragility: When changes in code cause other, seemingly unrelated features to break
- Immobility: When components are difficult to separate for reuse
- Viscosity: When the cost to support existing behavior outweighs the cost to improve the design
- Repetition: When code is repeated. Duh.
- Opacity: When code is difficult to read and understand
- Complexity: When failed attempts to apply abstractions and patterns have only made things worse
Similar to the SOLID acronym itself, these smells are an outward expression of underlying causes. Once you start feeling the pain of one of these smells, you’ll want to set to work identifying its source. There are several activities that can help point you in the right direction.
Churn is a measure of how frequently something changes. Files in your system that change frequently are a sign that you didn’t come up with the proper abstractions the first time around. There’s even a ruby gem to help you automatically quantify churn in your projects. But don’t stop looking at the file system. Listen for churn in your conversations about the product and track topics that come up in retrospectives. Occasionally dig into the records to detect trends. Signs of churn can also show up on your project planning boards and in your inbox.
Measure code complexity
By measuring the complexity of your code, you can get an idea about which areas of the codebase will be most resistant to change. There are several ways to quantify complexity, which I won’t cover here, but great services like Code Climate offer an automated solution that will allow you to detect existing hotspots in your code, and warn you when a new set of changes has muddied the water.
Look for missing interfaces
Anywhere a function or method is called, or for that matter, anywhere a message is sent, e.g. even from browser to server, this represents “flow of control” — if a module X calls function f on module Y, then control “flows” from X to Y. This typically means that either X needs to depend on Y or vice versa. But if you apply the Dependency Inversion Principle, and introduce an interface, now both X and Y can depend on the interface. Tests can now supply a dummy or mock interface in place of the real thing, and when the time comes that you need to support some new interface, you can write the new interface and inject it without needing to change the object that expects to use the interface — in this way your systems can become Open for extension, but closed for modification.
If you’re handy with UML diagrams, take inventory of all your system’s internal and external dependencies, and for each one, draw an arrow to represent “flow of control” and an arrow to represent the direction of the dependency. As “Uncle” Bob Martin points out in this lecture on the topic of SOLID, you want the direction of your dependency to point against the flow of control.
Listen for missing nouns in your meetings — “that criteria select dropdown menu thingy” that keeps coming up in conversation? It should have a name! If a UI component in your product doesn’t already have a name, chances are your code doesn’t encapsulate its behavior as well as it could.
Look for Connascence
Once you’ve identified areas of your code in need of some housekeeping, it helps to know what specific things to look for and root out. For this, the concept of Connascence is handy. The word comes from the Latin for “born together” — essentially, we’re talking about dependencies.
When any two components need to agree with one another for the system to work, we have connascence.
With familiarity about types of connascence, it’s possible to be more communicative and specific with your feedback when reviewing a colleague’s pull request.
When to invest in refactoring?
Developers who are new to test-driven development (TDD) often worry that tests take precious time and that they won’t be able to afford to take that time away from implementing requirements. With practice and experience, it eventually becomes clear that tests not only save time in the long run, but they also help you discover edge cases and alternatives that you didn’t expect.
Similarly, refactoring techniques like dependency injection will help your tests today by allowing you to use a mock object. When a need for a change inevitably shows up, the refactor will pay off again — your abstractions will be ready to support changes that you don’t know you need yet.
Executing Your Refactor
Once you’ve started asking the questions, you’ll begin to identify the design smells, and even before you’ve mapped out the terrain with automation tools and static analysis, you’ll probably have ideas about where the code needs to change. Before jumping into a refactor, it’s important that the design smells are tracked back to individual code smells to avoid introducing unnecessary complexity by applying the wrong pattern.
Sourcemaking’s section on refactoring provides clear summaries of a wide variety of smells and antipatterns that can occur at the code level, along with prescriptions for each problem. Instead of reiterating lists like that one, my encouragement for you is to carve out some dedicated time to study or review these patterns. Armed with the ability to name the smells when you see them will allow you to convey more information when reviewing pull requests or pairing on a tricky bug.
For those who want a more thorough dive into SOLID refactoring, Sandi’s book Practical Object-Oriented Design In Ruby (or POODR) is clear, well-organized, and provides excellent code refactoring examples. Even if Ruby isn’t your language of choice, this text is a great read for anyone hoping to hone their object-oriented skills.
But now there are so many files!
Refactoring in this way leads to a greater number of smaller classes, modules, and files. I’ve often heard concern that now there will be more “places” to look when debugging. But I have found that fewer, “fatter” interfaces give a false sense of comfort and security. Sure, you may only have to look in one file, but you’ll have to take longer reading its fat guts and thinking about primitives when you could be focusing on your abstractions and how they interact.
The SOLID principles tend to be expressed from the perspective of the workhorse of object-oriented languages: The Class. If the principles are well-applied, this will be where the payoff will be most clear, but a pristine, well-curated class that successfully adheres to the SOLID principles only got that way through repeatedly asking good questions and answering them to the best of our ability.
In this way, I see the SOLID principles as being applicable to the entire process of software development. We should not only be asking these questions of the Class but about its methods and variable names. We should ask about the business needs, about expected user behavior, about all the components within the class, its collaborators, about the server environment, and about the greater contexts in which the application and organization function.