SOLID Principles Aren’t Principles

Have you ever found yourself pondering endlessly about whether a piece of code follow this or that SOLID principle? It’s as if following those principles is more important than the problem itself.

A hypothesis is that it has to do with the word “principle”. If SOLID are truly principles, we must always follow them. But should we?

To address this question, let’s first revisit SOLID, then study some definitions of “principle”, and finally evaluate if SOLID aligns with any of those definitions.

Revisiting SOLID

S.O.L.I.D. is a set of design so-called principles coined as an acronym by Robert “Uncle Bob” Martin, with each letter representing a key aspect of software design:

  • S for the Single Responsibility Principle: a particular view of cohesion that organizes code around potential changes.
  • O for the Open-Closed Principle: a way of promoting extensibility through abstractions.
  • L for the Liskov Substitution Principle: a strong view of sub-typing.
  • I for the Interface Segregation Principle: a technique for minimizing the “surface area” of non-cohesive code.
  • D for the Dependency Inversion Principle: an approach that decouples policies and business rules from implementation details.

These ideas are quite useful in Object-Oriented code and likely to be applied to other paradigms. But are they truly principles?

Fun fact: the concepts above were first published in book format in Agile Principles, Patterns, and Practices, released in 2002. But the acronym SOLID itself never appeared in this original edition nor in the 2006 C# edition. The order in the table of contents for both editions would form S.O.L.D.I.!

Defining Principles

What are principles? Let’s consult the Merriam-Webster dictionary:

  1. a comprehensive and fundamental law, doctrine, or assumption
  2. rule or code of conduct
  3. the laws or facts of nature underlying the working of an artificial device
  4. a primary source, origin
  5. an underlying faculty or endowment
  6. an ingredient (such as a chemical) that exhibits or imparts a characteristic quality
  7. a divine principle, GOD (when capitalized)

Are SOLID laws or rules? It doesn’t seem to be the case. Maybe they’re about natural facts or origin? Unlikely.

Examining Interface Segregation

Now that we’ve looked at what principles really mean, let’s examine the Interface Segregation Principle (ISP) a bit closer. It states that “Clients should not be forced to depend on methods that they do not use.”

When we have a non-cohesive piece of code, its clients end up having access to unneeded behavior.

Think of a class with numerous unrelated methods like the following:

public class Employee {
  public BigDecimal calculatePayment() {
    //...
  }
  public BigDecimal calculateTaxes() {
    //...
  }
  public BigDecimal calculateOvertime(List<Hour> hours) {
    //...
  }
  public void saveToDB() {
      //...
  }
  public static Employee retrieveById(Long id) {
    //...
  }
  public String toXML() {
    //...
  }
  public static Employee fromXML(String xml) {
    //...
  }
}

The Employee class above deals with:

  • Payment, taxes and overtime.
  • Saving and retrieving objects from a database.
  • Converting to/from XML.

Most of the time, code using this class only needs some of its methods. For example:

  • Finance-related code might use calculatePaymentcalculateTaxes, and calculateOvertime.
  • Persistence tasks would use saveToDB and retrieveById
  • Integration with other systems might call toXML and fromXML.

This situation goes against the ISP. Clients of the problematic class could mix up different usages, spreading lack of cohesion throughout the codebase.

A solution would be to create separate interfaces for each group of clients, eliminating dependency on unused behavior. Hence, we would segregate behavior to narrowly focused interfaces such as:

public interface EmployeeFinance {
    BigDecimal calculatePayment();
    BigDecimal calculateTaxes();
    BigDecimal calculateOvertime(List<Hour> hours);
}

public interface EmployeePersistence {
    void saveToDB();
    static Employee retrieveById(Long id);
}

public interface EmployeeXmlSerialization {
    String toXML();
    static Employee fromXML(String xml);
}

Our Employee class would implement the finer-grained interfaces above:

public class Employee implements EmployeeFinance,
  EmployeePersistence, EmployeeXmlSerialization {
  // rest of the code...
}

Now, clients of Employee could use it behind a specific mask like EmployeeFinance for finance-related code and so on, without even knowing about other behaviors.

Identifying the root cause

The underlying problem here lies in lack of cohesion: a class is doing too much. And this means it’s a violation of the Single Responsibility Principle (SRP), as Employee has at least three reasons to be changed, related to finance, persistence and system integration. We should refactor this class to be more cohesive, having less reasons to change.

Actually, this is almost the exact example arguing for SRP in Robert Martin’s book UML for Java Programmers.

If breaking apart a troublesome class isn’t an option, we can apply Interface Segregation.

Therefore, Interface Segregation is not much a principle as it’s a technique or a pattern: a solution in the specific context of not being able to refactor non-cohesive code.

Exploring the Author’s Perspective

So, if the ideas in SOLID aren’t truly principles, what do they represent?

Let’s turn to their creator, Robert Martin, who, in the 2002 book Agile PPP, described them as:

“These principles are the hard-won product of decades of experience in software engineering.
They are not the product of a single mind but represent the integration of the thoughts and writings of a large number of software developers and researchers.”

Hence, SOLID emerges more as a collection of pragmatic advice from seasoned software engineers rather than immutable laws or rules.

In his 2009 blog post Getting a SOLID start, Martin clarified his use of the term “principle”:

“The SOLID principles are not rules.
They are not laws.
They are not perfect truths.
They are statements on the order of ‘An apple a day keeps the doctor away.’
This is a good principle, it is good advice, but it’s not a pure truth, nor is it a rule.”

Martin further suggests that SOLID principles serve as mental frameworks for addressing common software development challenges:

“The principles are mental cubby-holes.
They give a name to a concept so that you can talk and reason about that concept. […]
These principles are heuristics.
They are common-sense solutions to common problems. They are common-sense disciplines that can help you stay out of trouble.
But like any heuristic, they are empirical in nature. They have been observed to work in many cases; but there is no proof that they always work, nor any proof that they should always be followed.”

Characterizing SOLID as names for common solutions to recurrent problems resembles the definition of design patterns. It offers guidance within a specific context.

In his 2003 book UML for Java Programmers, Martin argues that we shouldn’t always apply SOLID and shows the effects of doing so:

“It is not wise to try to make all systems conform to all principles all the time, every time.
You’ll spend an eternity trying to imagine all the different environments to apply to the [Open/Closed Principle], or all the different sources of change to apply to the [Single Responsibility Principle].
You’ll cook up dozens or hundreds of little interfaces for the [Interface Segregation Principle], and create lots of worthless abstractions for the [Dependency Inversion Principle].”

So, are SOLID principles really principles? No. They’re more like guidelines.

By viewing SOLID as guidelines coming from pragmatic advice, we can avoid needless complexity in our code and make more effective design decisions considering the unique contexts of our projects.

Restrictive Abstractions

Recently, I was discussing with João Júnior, an experienced software engineer and a close friend, about how we are sometimes tempted to create abstractions that end up restricting us.

A Caching Example

Caching is a must-have for most applications because it reduces the time to retrieve frequently accessed data, thus improving performance. We usually implement caching with in-memory key-value data stores such as Redis or Memcached.

A simple abstraction for caching would enable us to perform operations such as associating key-value pairs, retrieving and deleting them, handling expiration and checking for existing keys.

We could materialize this abstraction in the following Java interface:

public interface Cache {

    boolean set(String key, String value);
    String get(String key);
    boolean delete(String key);
    boolean expire(String key, int seconds);
    boolean exists(String key);
}

This interface is a simplified version of real caching abstractions from Java technologies such as the ones from Spring or JCache (JSR-107). Both are part of quite complex solutions, having more generic types and different capabilities. Also, annotations would be preferred to using Cache directly in most Java applications.

Sure, we could improve error handling and cache invalidation in the interface above. But our neat Cache abstraction could go very far! This abstraction would hide away the complexities of implementing clients for caching mechanisms like Redis or Memcached.

Eventually, if we decide to adopt any other caching mechanism with better scalability or performance, we could do it just by creating a new implementation for this interface, without changing (almost) any other code. The Open/Closed Principle in action!

When Abstraction Gets in Our Way

Some abstractions can present challenges in subtle ways. And even a well-designed abstraction might need to be adjusted if context changes.

What if we need to use Redis features like geospatial indexes, probabilistic data structures or even transactions? Our current simple abstraction would not fit those new use cases.

We might consider modifying our Cache interface to throw UnsupportedOperationException for implementations like Memcached, which lack those advanced features. However, this approach would not be a genuine solution but a consequence of weakening the notion of subtyping.

As we start depending more and more on Redis-specific capabilities, it would be difficult to generalize them back to other caching mechanisms.

In such a scenario, an abstraction that promoted extensibility and freed us to try new implementations would start to tie us up to old assumptions.

What would be our options to solve this? We could:

  • Reopen the closed abstraction, trying to find a new generalization.
  • Coexist the current abstraction for basic use cases with implementation-specific code for advanced functionalities.
  • Discard the current abstraction altogether.

The Spring framework, for instance, chose to have both generic and specific abstractions. In Spring applications, we can use caching abstractions for simpler caching needs. If we need specific capabilities, we could adopt more specialized modules such as Spring Data Redis.

ANSI SQL Can Be Restrictive

SQL is another example of a very sophisticated abstraction that can be restrictive—and leaky.

We can go very far using standard ANSI SQL. But, eventually, we end up having code specific to PostgreSQL or MySQL to optimize for performance.

Adhering to ANSI SQL could become so restrictive that it might prevent us from solving bottlenecks as data volume increases.

If our scenario really demands more than one database—for instance, software deployed on-premise within our client’s infrastructure—we would probably have to maintain separate optimizations for each one of them. This would lead to code duplication but, paraphrasing Sandi Metzprefer duplication over the restrictive abstraction.

So, Are Abstractions Useless?

Hold on! Abstractions are quite useful.

The absence of well-designed abstractions could result in code difficult to understand and to adapt to changing requirements.

And we can reap the benefits even from simple abstractions. We can go very far if we’re lucky enough to stick to basic usages.

But context changes. Trying to fit the new uses on the existing abstractions can be like fitting a square peg in a round hole.

No abstractions are definitive, closed for ever. We should keep revisiting them, evaluating if our scenario and assumptions are still the same.

If our current abstractions are too restrictive, we should rethink them.

Dev Multitask – React da Review Desbravando SOLID

SouJava na Campus – Refatoração: uma jornada guiada por design patterns | Alexandre Aquiles