If you tell me you’re a senior .NET developer, there’s a handful of assumptions I’m going to make about what you know. The “Things Every Senior .NET Developer Should Know” series of posts will look at those things. Today’s topic is everyone’s favorite set of principles, SOLID, a set of principles that can guide you towards creating more maintainable systems.
SOLID – What It Is, and Why You Should Care
SOLID is a set of five principles originally introduced by Robert Martin in his whitepaper, Design Principles and Design Patterns and more fully in The Principles of OOD. They are principles, not rules, that will help guide you towards better, more maintainable code when applied correctly. The principles are Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion (not to be confused with the somewhat related concept of Dependency Injection). SOLID helps us avoid bad designs. What does “bad design” mean? A badly designed system can be recognized by its rigidity (making a change requires that many other parts of the system change), its fragility (even simple changes cause unexpected parts of the system to break), and its immobility (behavior is not easy to reuse due to its tight coupling and complex dependencies). While the principles were created with object-oriented languages such as C++ and Java in mind, they are still as applicable today as they were when they were introduced over a decade ago.
A class should have one, and only one, reason to change.
The Single Responsibility Principle (SRP) is fairly simple to state, but in my opinion the most difficult to apply correctly. The purpose of the principle is to guide you towards a cohesive design, where all the operations on a class work to implement the same responsibility. This is important because responsibility is an “axis of change,” meaning a change in responsibility will cause your class to (obviously) change. When your class has multiple unrelated responsibilities, two things happen. First, you increase the likelihood that the class will change. By itself this isn’t the end of the world, but it leads to the second thing: the likelihood of breaking existing functionality increases. When behavior changes, anything that depended on the old behavior must also change. In classes with multiple responsibilities, there’s also the opportunity for a change related to one behavior to have a negative impact on the other behaviors.
A nice side effect of applying SRP is that it typically leads to classes that are smaller and simpler because it helps you to decompose behaviors into cohesive objects. Smaller classes are almost always easier to comprehend and to test. These two things will make the class easier to maintain down the road. Smaller, more-cohesive classes are also better candidates for reuse since they’re likely to have fewer dependencies.
As I said, SRP is simple to state, but it’s quite easy to apply incorrectly. Applied too stringently, SRP can lead you to violate core Object Oriented Design principles, particularly encapsulation. You can decompose behaviors too far and end up with a single behavior strewn across multiple classes. As I’ve said, encapsulation is, in my opinion, one of the most important of the OO principles. Violating encapsulation will have the opposite effect of SOLID’s intended purpose, leading to a system that is more difficult both to comprehend and to maintain. Therefore, you must apply SRP carefully. Consider the tradeoffs when evaluating to what degree to apply it. Remember that defining “responsibility” is mostly subjective. Is it valid to draw the responsibility boundary for a class at “Sends E-mails?” Or is it more appropriate to decompose the responsibility into “Sends New User E-mails” and “Sends Weekly Status E-mails?” That’s a bad example, but deciding what granularity is appropriate for defining responsibilities depends completely on the domain of your application.
SRP is easy to apply incorrectly. Fortunately, the remaining principles are easier to apply.
You should be able to extend a class’s behavior without modifying it.
If SRP is my least favorite (only because of the difficulty in applying it correctly), then Open-Closed Principle (OCP) is my favorite. To comply with OCP, you must treat existing code as if it is written in stone. “But how will we adapt to changing requirements?!?” By creating classes that are open for extension through the creation of new classes. The benefits of being able to change the behavior of a system without modifying existing code should be obvious: it is usually far easier to write new code than to change existing code. When you change existing code, you run the risk of breaking the code as well as any code that depends upon it. By adhering to OCP and going for a more compositional approach, you lessen the risk of making changes to a system.
Achieving OCP is where design patterns truly shine. Many patterns (which I’ll cover in a future post) can provide you with OCP-compliance. The ubiquitous nature of Inversion of Control containers in .NET also makes it easier to achieve OCP by making it easy to depend on abstractions, a topic discussed further below.
Even with a good grasp of design patterns, achieving OCP can sometimes be difficult. I’ve also sometimes felt that pursuing OCP in my applications was a violation of YAGNI or KISS. At the end of the day though, I’ve typically found that OCP can be achieved with a minimal amount of additional effort, and that the end payoff justified the added complexity.
Derived classes must be substitutable for their base classes.
The Liskov Substitution Principle (LSP) deals with how derived types should behave with respect to their base type. All types have both an explicit and implicit contract. The explicit contract is the visible members (methods, properties, etc) of the type, while the implicit contract is the set of assumptions around how those members behave. Robert Martin gives an example of a Liskov violation using a Circle class. A Circle is a special type of Ellipse whose foci are both equal. The base Ellipse class allows the foci to be set to different values, a scenario that doesn’t make much sense on the derived Circle class.
While Circle could ignore the second foci, as shown above, or perhaps could even throw an exception if the foci are not the same, doing so would violate the implicit contract established by the base Ellipse class, which allows the foci to be set independently. This is problematic since consumers of Ellipse could, by way of polymorphism, be handed an instance of a Circle. Assumptions the consumer makes based on Ellipse must be honored by Circle in order to satisfy LSP, otherwise the consuming code could break, or it could require changes to support every new type that derives from Ellipse. As you can imagine, with a more complex type hierarchy, the impact of LSP violations increases dramatically.
Adhering to LSP may sound easy, but in practice it can be difficult. While the .NET compiler will ensure that your type meets its explicit contracts, it won’t do much to help you meet implicit contracts. Well, not unless you are utilizing the Code Contracts feature found in the Premium Edition of Visual Studio, but I confess that I have yet to try Code Contracts. At the end of the day, it falls on you to be aware of the implicit contracts of types you inherit from/interfaces that you implement and to ensure that you honor them.
Make fine grained interfaces that are client specific.
Another fairly simple one, the Interface Segregation Principle (ISP) guides you towards thin interfaces that expose only the functionality needed by a particular type of consumer. Consider a generic Order Processing service. The service may provide many operations, such as submitting a new order, checking the status of an existing order, or canceling an order. However, will every type of client require all of these operations? Probably not. Instead of exposing all of these operations through a single interface, a design that adheres to ISP will segregate the operations into multiple interfaces where each is tailored to the specific needs of a type of client. The interfaces may even expose similar operations but whose signatures are tailored to the particular needs of the consumer. For example, canceling an order may require different information when it is canceled for administrative reasons (perhaps the item no longer available) than if it is canceled by a customer accessing the system through an eCommerce site.
Why is ISP important? Consider a design which utilizes a single interface for all consumers of a particular service. All of those consumers are now coupled together because they share the interface. Changing the interface requires that all the clients change. In a system that adheres to ISP, each type of consumer has its own dedicated interface, which means the interface for one type of consumer can change without impacting other types of consumers. Following ISP leads to less coupling, which is always a good thing.
Do note that having separate interfaces does not necessarily mean that you must have separate implementations. .NET supports multiple interface implementations. In practice though, you may find that it makes sense to separate the implementations as well in order to cleanly separate responsibilities (see the Single Responsibility Principle above).
Depend upon Abstractions. Do not depend upon concretions.
The final of the SOLID principles is the one I’ve heard misstated the most often. The Dependency Inversion Principle (DIP) states that you should always depend on abstractions (either abstract classes or interfaces in .NET), not on concrete types. A dependency on a concrete type is one of the strongest couplings you can introduce, and as I’ve said previously, coupling is something we want to minimize in our designs. Stated another way, DIP says that high-level policy (the real meat of your system) should not depend on details. Intuitively, this makes sense. If our system’s “policy” is the core of our business logic, coupling policy to low-level implementation details (such as the fact that our data is stored in SQL Server 2008) greatly limits the flexibility of our application, makes testing our business logic more difficult, and limits our options for reuse.
Consider a system that does not adhere to DIP. The system needs to store data, so it uses SQL Server. Instead of utilizing an abstraction, the system’s core logic utilizes ADO.NET directly. This means that the system’s core logic is no longer testable in isolation. Because the logic depends directly on concretions (ADO.NET’s API), any code that exercises the logic will also exercise ADO.NET and the underlying database. While there is certainly value in full system integration tests, being able to test logic in isolation is essential.
How does one achieve Dependency Inversion in .NET? Again, design patterns certainly help, but at some point something has to create a concrete instance of a type that implements an abstraction. Coupling can’t be eliminated completely. Fortunately, Inversion of Control containers, once again, help you craft SOLID code. By handling the responsibility of object creation, IoC containers allow your classes to depend only on abstractions and to leave the instantiation of concrete types to the container.
One often overlooked tidbit about Dependency Inversion is how it differs from Dependency Abstraction. The two principles are not completely distinct, but Dependency Inversion is more than just replacing references to MyConcreteService with references to IMyService. Dependency Inversion is the idea that you don’t depend on details, either directly (through concrete types) or indirectly (through poorly-disguised interfaces). Another aspect that sets them apart is who owns the abstraction. If all you are doing is extracting an interface from your concrete type and replacing references, its natural to think that the concrete type (the detail) owns the abstraction it implements. However, when applied correctly, you’ll realize that is actually the policy that owns the abstraction. The abstraction should be crafted to suit the needs of the consumer (the policy), not tailored to the implementation, as noted in ISP above.
There’s no silver bullet…
Sadly there is nothing magical about the SOLID principles. Memorizing them won’t instantly turn you from a junior developer into a senior one. Learning to apply them correctly takes time and practice. You cannot apply them blindly, and doing so can lead you right past “good design” and back into the realm of bad design. Still, mastering SOLID is a worthwhile effort. Personally, I feel that I enjoy creating software more now that I understand SOLID than I did before I heard of the principles, and I am certainly much more aware of how coupling manifests itself in the real world and of the risks it poses.
Up next in the “Senior Developer” series, we’ll look at one of the tools that goes a long way towards helping you achieve SOLID designs: Inversion of Control.