SOLID

SOLID principles are set of design principles regrouped by Robert C. Martin and initially appearing in his seminal book Agile Software Development.

They're all the rage recently, unfortunately in a bad way. Kids nowadays like to throw to the bin anything that resemble software engineering, things like Object-Oriented Design, Test-Driven Development and SOLID Principles. But don't be fooled, they're bullet-proof ideas that, wether you want it, appear not only in well crafted object-oriented applications but also in modern software architecture.

We can argue that most of the recent "discoveries", including Event-Driven Architecture, Microservices and Distributed Infrastructure, all align cleanly to Object-Oriented principle and SOLID by their very design.

I will prove this point later in this e-book.

But let's get focused !

What's about these principles ?

First and foremost, SOLID principles are object-oriented design principles. They have been conceived, grouped together and elaborated in order to guide the development of object-oriented software.

However, the spirit of these principles can, to some degree, be ported to other paradigms with ease, albeit differently. The point is that they're targeted at object-oriented software, such that we can maximally take advantage of them in object-oriented designs.

So, what's so important about these principles ?

SOLID principles are, as we're going to see, principles that guide us toward proper OO design. It helps us move forward design that is truly aligned with our initial object-thinking, that is :

  • Abstraction : reducing the knowledge of the overall system by raising the language of the program by using high-level concepts and hiding low-level details

  • Encapsulation : reducing the knowledge of the overall system by spreading responsibilities between objects

  • Polymorphism : reducing the knowledge of the overall system by making contract dependencies instead of object dependencies (I don't know who you are, but I know what you can do for me)

Single Responsibility Principle

The common way to mis-interpret this the Single Responsibility Principle is to state.

An object does one thing and one thing only.

What's wrong with this sentence ?

It's confusing. It can be interpret in many ways. It doesn't help. So what's a better way to phrase it ?

An object should have only one reason to change.

That's better. Now we can actually think about it in objective way by analyzing what are the vectors of change of an object.

Let's take an object.

class User {
    private id: string;
    private name: string;
    private emailAddress: string;
    
    rename(nextName: string) {
        this.name = nextName;
    }
    
    changeEmailAddress(nextEmailAddress: string) {
        this.emailAddress = nextEmailAddress;
    }
}

Can you think of reasons that will make this class change ? I can think of a few :

  • Adding new properties and new methods like birthDate

  • Changing the type of name or emailAddress by using Value Objects

  • Adding validation to the name and the emailAddress

  • Emitting events after the name or emailAddress have been changed

Yeah, User has many reasons to change it seems. So the definition is still incomplete. Robert C. Martin adds :

An object should be responsible to one and only one actor

Now we have a much, much better idea. It turns out that the Single Responsibility Principle is about the people who have a power over why the class changes.

This principle is heavily inspired by a paper by David Parnas who also came to the conclusion that the design of architectural modules should be driven by responsibility, by the people.

That's also a big idea of Domain-Driven Design and Bounded Contexts, where it seems that Bounded Context should be aligned toward actors of the system. But we're going out of our way.

So the important word is actor. What do we mean by actors ? Actors are really anybody with a reason to care about the system :

  • Users

  • Administrators

  • C-levels

  • Marketing & Sales people

However, these are defined rather broadly. Think about it : who can request that we support birthdates ?

  • Product Owners

  • Users themselves

  • CEO because why not

Does that mean our User object violates SRP ?

Not exactly. Actors ought to be categorized by Business Units. So really, the Product Owner and the CEO would request that change on behalf of the Users, and they belong to the same unit.

In a bigger system, Users would be a term too generic and another word would better define them. I recently worked on a psychology apps where actors were the patients, the therapists, the administrators and the analysts.

As you can see, it's not such an easy concept. It's actually quite a lot of work to define what is a responsibility and what is a reason to change. In essence, the Single Responsibility Principle is by far the most complex of the 5 principles.

Open Closed Principle

The Open Closed Principle is probably one of the most important of the 5 principles when it comes to object-oriented design. It really captures the soul of object-orientation.

The Open Closed Principle states that a class or module should be open to extension, but closed to modification.

How can something be extended without being modified ?

Let's take a slight example.

class UserService {
    createUser() {
        // Some code happens
        this.mailer.sendEmail(to, subject, body);
    }
}

Here, we fulfil the imaginary requirement that when a user is created, an e-mail is sent. Everything is good so far.

Now, we're asked to test that code, yet we can't run a local e-mail server. So, when running tests, we need to just collect all the e-mails in an array and pretend that array represents all the e-mails that have been sent.

Things get complicated.

class UserService {
    createUser() {
        if (IS_TEST_ENVIRONMENT) {
            this.testMailer.add(to, subject, body);
        } else {
            this.smtpMailer.sendEmail(to, subject, body);
        }
    }
}

It's a good sign that code like this usually trigger some negative reaction in most developers. The idea of making such a check in our code just to adapt to our test code feels strange to say the least, horrendous if we're honest.

Such code doesn't scale and scatters through the code base. Imagine how many classes need to send e-mails ? Now, they all have to include that stupid check.

How do we fix this problem ? It really is a two step program that you can use and reuse at will :

  • Think deeply about the abstraction you're trying to make and create an interface for it

  • Implement as many concrete implementations as you need

A little bit of analysis for this case is de rigueur.

The Commonality / Abstraction

The big idea here is that we need to send an e-mail, so the abstraction we're looking for is that of a mailer, mail sender, mailbox, whatever floats your boat.

We can think harder about this. What if we want to send SMS or PUSH notifications in the future ? That's an other vector of change. The question isn't "what are we trying to do" but rather "what are we trying to achieve ?". We'd want to think deeper about what's the commonality between e-mails, SMS and PUSH. My thoughts lead me to think about the idea of a Notification.

But right now, we're only concerned with the idea of sending e-mails. That notification thing may never come, and we don't want to think too far ahead.

But keep this first step of the process in mind, because it's a key principle of object-orientation. Thinking deeply about your abstractions is the key to supple, extensible code. Don't think about the implementation (the how), think about the intent (the why).

I'd go with something like that.

interface Mailer {
    send(to: Recipient, subject: Subject, body: Body);
}

class Recipient {}
class Subject {}
class Body {}

The Variability / Implementation

We now have the big ideas, we can create different classes with varying implementations :

  • One that actually send e-mails to an SMTP server

  • An other that collects them in an array for testability

They would look like this.

class SmtpMailer implements Mailer {
    private readonly client;
    
    send(to, subject, body) {
        // Code
    }
}

class LocalMailer implements Mailer {
    private readonly emails = [];
    
    send(to, subject, body) {
        this.emails.push({ to, subject, body });
    }
}

Thus, our initial program would look like this.

class UserService {
    private readonly mailer: Mailer;
    
    createUser() {
        this.mailer.sendEmail(to, subject, body);
    }
}

Our UserService is blissfully ignorant of which object is receiving the sendEmail message. It simply knows that it conforms to the interface it expects, Mailer.

From the point of view of UserService, any object that implements that interface will do. We no longer need to think about who's receiving that message.

Moreover, we can add as many implementations as we need. Say one day we want to use Amazon SES API to send e-mails, we just need to add another implementation.

And that's the essence of the Open Closed Principle. We find the right abstraction and create as many implementations as we need, such that we can add behavior to our program without modifying it.

Read it again. Adding an Amazon SES implementation will not impact any of the code.

Except one part of the code : the small bit responsible for instantiating the object in the first place. Either a Factory, or your favorite Inversion of Control container.

Factories are where conditionals go die.

Sandi Metz.

This is directly linked to the practice Separate Instantiation from Usage.

Liskov Substitution Principle

The Liskov Substitution Principle is also a mouthful.

Liskov's notion of a behavioural subtype defines a notion of substitutability for objects; that is, if S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program

We don't need to get involved into the details of the principle to understand it, so let's go back to our mailing example.

interface Mailer {
    send(to: Recipient, subject: Subject, body: Body): Promise<void>;
}

Put simply, LSP says that all the objects that are a subtype of Mailer (read : all the objects that implement or subclass Mailer) must conform to its contract.

So what does the contract of Mailer says, exactly ? Quite a lot of things.

  • It expects a valid Recipient, Subject and Body

  • It will send an e-mail and succeed in any case

  • The send method is asynchronous

  • When resolved, the e-mail is guaranteed to be sent

If any of these invariants become false, you've violated the Liskov Substitution Principle.

Say, for example, that one of our mailer throws because the e-mail couldn't be sent. What would happen ?

The calling code, the one manipulating Mailer, doesn't expect it. Nowhere is it written that Mailer will fail. It's even part of the contract that Mailer shouldn't fail unless in very exceptional situations (like a stack overflow or a memory corruption error).

Take another example. Maybe we have one Mailer that only accepts textual bodies and doesn't send HTML bodies. Should it throw ? Probably not, because that would violate the contract that seems to say that any form of e-mail body should be accepted.

What are our solutions here ? We really have two :

  • Either state that no implementation will ever throw except in exceptional conditions that cannot be anticipated

  • Or anticipate that some implementation will throw some specific error and, in this case, anticipate that some children will only partially support the contract

That's often the two options you have when dealing with contracts that conform to LSP (and you SHOULD adhere to LSP !). It really boils down to either be very strict with your interface or anticipate what may come.

Interface Segregation Principles

To be done.

Dependency Inversion Principle

To be done.

Last updated