SOLID Principles

SOLID Principles

Key factors to have better software design.

ยท

16 min read

Some of the most important things to consider when designing a software is to make software entities reusable, flexible, scalable, robust, and maintainable. Implementing SOLID principles can impacts these aspects of software design in a good way.

Introduction

SOLID is an abbreviation which translates to some set of principles created by Robert Cecil Martin a.k.a Uncle Bob. SOLID principle states that the object must have single responsibility (S), it must be open for extensions but closed for modification (O), child class must be able to replace parent class (L), unwanted behavior must be excluded from the object (I), use abstractions to ensure modules depends on interfaces rather than concrete implementation (D).

Single Responsibility

First principle of SOLID stands for Single Responsibility. It means that any unit or block of code, whether its a class, or method, or a function must have single responsibility. Each unit should be responsible for only one functionality. This makes the software more easy to debug. Therefore, it states that your unit of code must be specific.

Demonstration

Here's an scenario demonstrating good design implementing single responsibility and bad design not implementing it.

Imagine a auth module in an web application where user needs to register themself. If user inputs correct information, they will receive an email confirming their identity for this web application.

First approach. A more naive (Not implementing Single Responsibility).

/* Data model used for demonstration */
public class UserInputs
{
    public required string UserName { get; set; }
    public required string Email { get; set; }
    public required string Password { get; set; }
}
public class Auth(UserInputs inputs)
{
    private UserInputs _inputs = inputs;
    private string[] Users = ["ram", "sam", "john", "tom", "hari"];
    private string[] Emails = ["ram@mail.com", "sam@mail.com", "john@mail.com", "tom@mail.com", "hari@mail.com"];

    public bool VerifyUser()
    {
        if (string.IsNullOrWhiteSpace(_inputs.UserName) || string.IsNullOrWhiteSpace(_inputs.Email) || string.IsNullOrWhiteSpace(_inputs.Password))
        {
            Console.WriteLine("Username, Email and Password are required field.");
            return false;
        }
        if (Emails.Contains(_inputs.Email))
        {
            Console.WriteLine("Email already exists. Please sign in.");
            return false;
        }
        if (Users.Contains(_inputs.UserName))
        {
            Console.WriteLine("User name already exists.");
            return false;
        }
        if (_inputs.Password.Length < 8)
        {
            Console.WriteLine("Password length must be atleast 8 characters.");
            return false;
        }
        Console.WriteLine($"New user {_inputs.UserName} has been created.");
        // user's inputs seems fine, now send them email confirming their identity.
        Console.WriteLine("--- New email from Auth Module ---");
        Console.WriteLine($"Account has been created successfully. \n" +
            $"Username: {_inputs.UserName} \n" +
            $"Email: {_inputs.Email} \n" +
            $"Password: {_inputs.Password} \n");
        return true;
    }
}

Above code violates the Single Responsibility Principle as the method should have a singular focus. VerifyUser() has multiple responsibility like validating user, creating user, and sending confirmation email. If I had to modify my validation logic, I need to modify VerifyUser(), which consists of multiple responsibility. Therefore, this codebase will become hard to test and maintain.

// calling code and output when single responsibility principle is applied
var user1 = new UserInputs()
{
    UserName = "sushant",
    Email = "sushant@mail.com",
    Password = "password"
};

badExampleCode::Auth badExample = new badExampleCode::Auth(user1);
badExample.VerifyUser();
/*
Output: New user sushant has been created.
        --- New email from Auth Module ---
        Account has been created successfully.
        Username: sushant
        Email: sushant@mail.com
        Password: password
*/

Before moving on, you can try refactoring it to enhance the design.

Here's another approach which is much more better than the first approach and follows single responsibility principle.

In the above code, we need to separate 3 things to achieve single responsibility: Validation, Registration, and Confirmation email. So I will add: ValidateUser() for validation, RegisterUser() for registration and SendEmail() for email confirmation. Sending email is useful in other modules as well. So I will create a separate class for it.

public class Auth(UserInputs inputs)
{
    private UserInputs _inputs = inputs;
    private string[] Users = ["ram", "sam", "john", "tom", "hari"];
    private string[] Emails = ["ram@mail.com", "sam@mail.com", "john@mail.com", "tom@mail.com", "hari@mail.com"];

    public bool VerifyUser()
    {
        if (ValidateUser() && RegisterUser())
        {
            /* when everthing goes fine */
            string toEmail = _inputs.Email;
            string subject = "New email from Auth Module";
            string body = $"Account has been created successfully. \n" +
                            $"Username: {_inputs.UserName} \n" +
                            $"Email: {_inputs.Email} \n" +
                            $"Password: {_inputs.Password} \n";
            EmailHelper email = new EmailHelper();
            email.SendEmail(toEmail, subject, body);
            return true;
        }
        /* when something goes wrong */
        return false;
    }

    public bool ValidateUser()
    {
        if (string.IsNullOrWhiteSpace(_inputs.UserName) || string.IsNullOrWhiteSpace(_inputs.Email) || string.IsNullOrWhiteSpace(_inputs.Password))
        {
            Console.WriteLine("Username, Email and Password are required field.");
            return false;
        }
        if (Emails.Contains(_inputs.Email))
        {
            Console.WriteLine("Email already exists. Please sign in.");
            return false;
        }
        if (Users.Contains(_inputs.UserName))
        {
            Console.WriteLine("User name already exists.");
            return false;
        }
        if (_inputs.Password.Length < 8)
        {
            Console.WriteLine("Password length must be atleast 8 characters.");
            return false;
        }
        return true;
    }

    public bool RegisterUser()
    {
        Console.WriteLine($"New user {_inputs.UserName} has been created.");
        return true;
    }
}

public class EmailHelper
{
    public void SendEmail(string to, string subject, string body)
    {
        Console.WriteLine($"Email to: {to}");
        Console.WriteLine($"Subject: {subject}");
        Console.WriteLine($"Body: {body}");
    }
}

This is much better design and follows single responsibility principle. This codebase promotes clearer separation of concerns which is organized, has better readability, flexible, and is easy to maintain. This results in overall betterment of software design.

At the end, building robust, flexible, and scalable software is the end goal of an ever evolving software requirements which is emphasized by following the single responsibility principle.

// calling code and output when single responsibility principle is applied
var user2 = new UserInputs()
{
    UserName = "jon",
    Email = "bones@mail.com",
    Password = "jonjones11"
};
goodExampleCode::Auth goodExample = new goodExampleCode::Auth(user2);
goodExample.VerifyUser();

/*
Output: New user jon has been created.
        Email to: bones@mail.com
        Subject: New email from Auth Module
        Body: Account has been created successfully.
        Username: jon
        Email: bones@mail.com
        Password: jonjones11
*/

Takeaway from Single Responsibility Principle

A whole single component may consist many pieces of components. A better software design would be to have each component have a single responsibility. It doesn't matter whether we consider the component as a whole or as individual pieces doing their part. Each component must have a specific responsibility.

In the first approach, VerifyUser() component had multiple responsibility. We decompose that and created multiple separate component. Finally, we compose all the component as whole.

Open/Close Principle

Second principle of SOLID stands for Open/Close Principle. It means that an software entity must be open for extension and close for modification. Software entities can be class, module, method, or a function. Therefore, it states that your software entities should allow new functionalities without hampering existing behavior. To achieve Open/Close principle, we mostly use inheritance and interfaces.

Demonstration

To demonstrate Open/Close principle, I will use the same example I used above (Auth module in an web application).

Open/Close principle state an entity should be open for extension and close for modification. I will create an abstract class which consist authentication logic.

public abstract class Authenticate
{
    private string[] Users = ["ram", "sam", "john", "tom", "hari"];
    private string[] Emails = ["ram@mail.com", "sam@mail.com", "john@mail.com", "tom@mail.com", "hari@mail.com"];

    public bool VerifyUser(UserInputs _inputs)
    {
        /* when everthing goes fine */
        if (ValidateUser(_inputs) && RegisterUser(_inputs))
        {
            string toEmail = _inputs.Email;
            string subject = "New email from Auth Module";
            string body = $"Account has been created successfully. \n" +
                            $"Username: {_inputs.UserName} \n" +
                            $"Email: {_inputs.Email} \n" +
                            $"Password: {_inputs.Password} \n";

            EmailHelper email = new EmailHelper();
            email.SendEmail(toEmail, subject, body);
            return true;
        }

        /* when something goes wrong */
        return false;
    }

    private bool ValidateUser(UserInputs _inputs)
    {
        if (string.IsNullOrWhiteSpace(_inputs.UserName) || string.IsNullOrWhiteSpace(_inputs.Email) || string.IsNullOrWhiteSpace(_inputs.Password))
        {
            Console.WriteLine("Username, Email and Password are required field.");
            return false;
        }
        if (Emails.Contains(_inputs.Email))
        {
            Console.WriteLine("Email already exists. Please sign in.");
            return false;
        }
        if (Users.Contains(_inputs.UserName))
        {
            Console.WriteLine("User name already exists.");
            return false;
        }
        if (_inputs.Password.Length < 8)
        {
            Console.WriteLine("Password length must be atleast 8 characters.");
            return false;
        }
        return true;
    }

    private bool RegisterUser(UserInputs _inputs)
    {
        Console.WriteLine($"New user {_inputs.UserName} has been created.");
        return true;
    }
}

When I implement this abstract class, authentication logic will be close for modification but open for extension.

public class Auth : Authenticate
{
    public bool IsPastUser(UserInputs _inputs)
    {
        var pastUserList = new List<string>()
        {
            "volk@mail.com", "chael@mail.com", "topuria@mail.com", "islam@mail.com", "sushantpant@mail.com"
        };

        if (pastUserList.Contains(_inputs.Email))
            return true;

        return false;
    }
}

Here, Auth implements Authenticate abstract class. Authenticate is close for modification. It is not possible to override VerifyUser() because it is not marked to be overridden (virtual). I added a new method IsPastUser() to check if the user had already registered before. I can extends this class and add more features as I want. This makes Auth class do exactly what it mean to do that is to authenticate (Close for modification) and add more features (Open for extension).

// calling code and output
var user3 = new UserInputs()
{
    UserName = "sushantpant",
    Email = "sushantpant@mail.com",
    Password = "p@ssWo3d"
};

openclosePrinciple::Auth openCloseP = new openclosePrinciple::Auth();
openCloseP.VerifyUser(user3);
if (openCloseP.IsPastUser(user3))
    Console.WriteLine($"Welcome back {user3.UserName}.");

/*
Output: New user sushantpant has been created.
        Email to: sushantpant@mail.com
        Subject: New email from Auth Module
        Body: Account has been created successfully.
        Username: sushantpant
        Email: sushantpant@mail.com
        Password: p@ssWo3d

        Welcome back sushantpant.
*/

Takeaway from Open/Close Principle

The Open/Closed Principle emphasizes software entities to be open for extension but closed for modification. This makes adding new functionalities and behavior to a software entity easy without hampering the default behavior of other components.

Liskov Substitution Principle

Third principle of SOLID stands for Liskov substitution Principle. It means that a child class should be able to replace a parent class, inheriting all the behaviors of the parent class. Therefore, according to the Liskov Substitution Principle, objects of the parent class can be replaced with objects of the child class without affecting the correctness of the program. Applying Liskov substitution principle is possible by using inheritance. If you need a new object with new functionalities but majority of it being same as some other object, you just extend that object and add your new functionalities and behavior.

Demonstration

Imagine a new requirement in our previous example of Auth Module.

For example: Send a discount coupon to a new user.

Firstly, we don't want to do any changes in the original Auth object. Just add a new feature to send discount coupon. To do this, I will create a new class called AuthWithDiscount which will be derived from Auth.

public class AuthWithDiscount : Auth
{
    public void SendDiscountCoupon(string email)
    {
        Random rand = new Random();
        int discount = rand.Next(0, 30);

        var arr = new string[] { "A", "B", "C", "Z", "X" };
        var discountCoupon = new StringBuilder();
        for(int i = 0; i < 3; i++)
        {
            discountCoupon.Append(arr[rand.Next(i)]);
            discountCoupon.Append(rand.Next(i+i));
        }

        string subject = "Discount for new user.";
        string body = $"Thanks for joining. To celebrate this, here is a discount of: {discount}%. " +
            $"Discount coupon: {discountCoupon}";
        EmailHelper emailHelper = new EmailHelper();
        emailHelper.SendEmail(email, subject, body);
    }
}

Here, AuthWithDiscount (child class) will have all behavior of Auth (parent class), with additional behavior. Now back to definition of Liskov substitution principle, child class should be able to replace parent class. In our case, AuthWithDiscount will replace Auth class and still maintain the same functionality of authenticating.

// calling code and output
var user4 = new UserInputs()
{
    UserName = "dawg",
    Email = "dawg@mail.com",
    Password = "handshakeleema"
};

AuthWithDiscount authWithDiscount = new AuthWithDiscount();
if(authWithDiscount.VerifyUser(user4))
    authWithDiscount.SendDiscountCoupon(user4.Email);

/*
Output: New user dawg has been created.
        Email to: dawg@mail.com
        Subject: New email from Auth Module
        Body: Account has been created successfully.
        Username: dawg
        Email: dawg@mail.com
        Password: handshakeleema

        Email to: dawg@mail.com
        Subject: Discount for new user.
        Body: Thanks for joining. To celebrate this, here is a discount of: 16%. Discount coupon: A0A0A1
*/

Takeaway from Liskov Substitution Principle

The Liskov substitution Principle (derived class should be able to replace base class) promotes code reusability, maintainability, scalability, and extensibility of an software object. This results in a more flexible software design.

Interface Segregation

Fourth principle of SOLID stands for Interface Segregation. Interfaces are use to define the behavior that a class has to provide. The Interface segregation principle suggests that interface must be tailored to object. It must not contain irrelevant functionalities. If a class/object only needs a subset of functionalities from an interface, it should not be burdened with unnecessary methods or properties. By defining interfaces that are object-specific, classes can implement only the interfaces that are relevant to their functionality. This promotes a cleaner and more modular design approach.

Demonstration

I will continue with my previous example of Auth module.

In Auth module, we can add a lot of feature. Let us consider 3 ways a user can register: social media account, email account, or phone number. For user from North America, all these 3 options are available. For APAC user, only social media and email account is available.

There can be multiple type of authentication. Like: auth with discount, auth for APAC user, auth for North America user, and more. Specifically auth has specific behavior. APAC has some other specific behavior. Therefore, I will create set of behavior for auth with email, auth with mobile no, and auth with social media.

public interface IEmailAuth
{
    public bool VerifyUserWithEmail(string email, string password);
    public bool IsEmailConfirmed(string email);
}
public interface IMobileAuth
{
    public bool VerifyUserWithMobile(string mobileNo, string password);
    public bool IsMobileConfirmed(string mobileNo);
}
public interface ISocialMediaAuth
{
    public bool VerifyUserWithSocialMedia(int socialMediaId, string userName, string password);
    public bool IsSocialMediaConfirmed(string userName);
}

APAC user can register themself by using email address or social media. Therefore, auth for APAC user will implement IEmailAuth, and ISocialMediaAuth.

public class AuthForAPAC : IEmailAuth, ISocialMediaAuth
{
    public bool IsEmailConfirmed(string email)
    {
        if (email is not null && email.Contains('@'))
        {
            Console.WriteLine("Email is verified.");
            return true;
        }
        return false;
    }

    public bool IsSocialMediaConfirmed(string userName)
    {
        var listOfSocialMediaUsername = new string[] { "jon", "dawg", "sushant" };
        if (userName is not null && listOfSocialMediaUsername.Contains(userName))
        {
            Console.WriteLine("Social media is verified.");
            return true;
        }
        return false;
    }

    public bool VerifyUserWithEmail(string email, string password)
    {
        if (email.Contains('@') && email.Contains('.') && password.Length > 8)
            return true;

        return false;
    }

    public bool VerifyUserWithSocialMedia(int socialMediaId, string userName, string password)
    {
        var listOfSocialMediaUsername = new string[] { "jon", "dawg", "sushant" };
        if (socialMediaId > 1 && userName is not null && listOfSocialMediaUsername.Contains(userName) && password.Length > 8)
            return true;

        return false;
    }
}

In the above code, only functionalities that is relevant to auth for APAC user is added.

Now for american user, they will have options to register with email, mobile or social media handle. Therefore, it will implement IEmailAuth, IMobileAuth, and ISocialMediaAuth.

public class AuthForAmerica : IEmailAuth, ISocialMediaAuth, IMobileAuth
{
    public bool IsEmailConfirmed(string email)
    {
        if(email is not null && email.Contains('@'))
        {
            Console.WriteLine("Email is verified.");
            return true;
        }
        return false;
    }

    public bool IsMobileConfirmed(string mobileNo)
    {
        if (mobileNo is not null && mobileNo.Contains("+123") && mobileNo.Length is 10)
        {
            Console.WriteLine("Mobile is verified.");
            return true;
        }
        return false;
    }

    public bool IsSocialMediaConfirmed(string userName)
    {
        var listOfSocialMediaUsername = new string[] { "jon", "dawg", "sushant" };
        if (userName is not null && listOfSocialMediaUsername.Contains(userName))
        {
            Console.WriteLine("Social media is verified.");
            return true;
        }
        return false;
    }

    public bool VerifyUserWithEmail(string email, string password)
    {
        if(email.Contains('@') && email.Contains('.') && password.Length > 8)
            return true;

        return false;
    }

    public bool VerifyUserWithMobile(string mobileNo, string password)
    {
        if (mobileNo is not null && mobileNo.Contains("+123") && mobileNo.Length is 10 && password.Length > 8)
            return true;

        return false;
    }

    public bool VerifyUserWithSocialMedia(int socialMediaId, string userName, string password)
    {
        var listOfSocialMediaUsername = new string[] { "jon", "dawg", "sushant" };
        if (socialMediaId > 1 && userName is not null && listOfSocialMediaUsername.Contains(userName) && password.Length > 8)
            return true;

        return false;
    }
}

Here, only functionalities that is relevant to auth for american user is added.

// calling code and output
var user5 = new UserInputs()
{
    UserName = "rambdr",
    Email = "rambdr@mail.com",
    Password = "mteverest8848"
};
AuthForAPAC authForAPAC = new AuthForAPAC();
var verify = authForAPAC.VerifyUserWithEmail(user5.Email, user5.Password); // register with email
if (verify)
    Console.WriteLine($"New account registered with email: {user5.Email}");
/*
Output: New account registered with email: rambdr@mail.com
*/

Finally, interface segregation suggests breaking down complex entities into small relevant entities (decomposition). This promotes flexibility and loose coupling (less attachment with other object) in software design.

Takeaway from Interface Segregation Principle

The Interface segregation principle suggest breaking down relevant functionalities into interface and only implement it to class where it is required. This results in a more flexible software design which is modular, flexible, and maintainable.

Dependency Inversion

Fifth and final principle of SOLID stands for Dependency inversion principle. Dependency inversion principle states multiple rules.

  • Firstly, it state that a higher level object should never depend on a concrete lower level object. Meaning, high-level modules should not directly rely on the specific implementations of low-level modules. Instead, both high-level and low-level modules should depend on abstractions, such as interfaces or abstract classes.

  • This bring up to the second rule, abstractions should not depend upon on details. Abstraction should not be coupled to the specific implementation details of lower-level modules.

  • Finally, the third rule states that details should depend on abstractions.

Covering up all, dependency inversion state that there must be an abstraction hiding implementation (use interface) and specify implementation to that interface (dependency injection). (Higher level module is an interface and lower level module is the implementation of that interface)

Demonstration

To demonstrate dependency inversion principle, I will work on same above example of auth module. Basically, there will be 2 auth option: auth with facebook and auth with email. So I will create 2 separate interface:

public interface IAuthWithFacebook
{
    public bool VerifyFacebookAcc(string facebookId, string email, string password);
}
public interface IAuthWithEmail
{
    public bool VerifyAcc(string email, string password);
}

Both of these auth option: auth with facebook IAuthWithFacebook and auth with email IAuthWithEmail will have a single concrete implementation. Therefore, I will create 2 classes AuthWithFacebook and AuthWithEmail to implement these interfaces.

public class AuthWithFacebook : IAuthWithFacebook
{
    public bool VerifyFacebookAcc(string facebookId, string email, string password)
    {
        var listOfSocialMediaUsername = new string[] { "jon", "dawg", "sushant", "joerogan" };
        if (facebookId is not null && listOfSocialMediaUsername.Contains(facebookId) && password.Length > 8)
            return true;

        return false;
    }
}
public class AuthWithEmail : IAuthWithEmail
{
    public bool VerifyAcc(string email, string password)
    {
        var listOfEmail = new string[] { "jon@mail.com", "dawg@mail.com", "sushant@mail.com", "joerogan@mail.com" };
        if (email is not null && listOfEmail.Contains(email) && password.Length > 8)
            return true;

        return false;
    }
}

The implementations (AuthWithFacebook and AuthWithEmail) depend on these interfaces, which is in line with the Dependency Inversion Principle. This allows the high-level modules (implementation) to remain decoupled from the specifics of how authentication is performed. This ensures that high-level modules depend on abstractions (interfaces), while the details of implementations are encapsulated within the low-level modules.

Then I will create another class called CallingCode to demonstrate dependency inversion.

public class CallingCode(IAuthWithFacebook _authWithFacebook)
{
    private readonly IAuthWithFacebook authWithFacebook = _authWithFacebook;

    public bool VerifyAuth(string userName, string email, string password)
    {
        return _authWithFacebook.VerifyFacebookAcc(userName, email, password);
    }
}

Here, CallingCode takes IAuthWithFacebook as argument by using latest C# 12 feature: primary constructor. VerifyAuth() does nothing but redirects to IAuthWithFacebook's method.

// main calling code
var user6 = new UserInputs()
{
    UserName = "joerogan",
    Email = "joerogan@mail.com",
    Password = "joeroganexperience"
};
var authWithFacebookObj = new AuthWithFacebook();
CallingCode callingCode = new CallingCode(authWithFacebookObj);
var facebookAuth = callingCode.VerifyAuth(user6.UserName, user6.Email, user6.Password);

if (facebookAuth)
    Console.WriteLine($"New account registered with facebook: {user6.UserName}.");

/*
Output: New account registered with facebook: joerogan.
*/

The CallingCode class depends on the abstraction provided by the interface (IAuthWithFacebook). It doesn't have direct knowledge of the concrete implementation (AuthWithFacebook), promoting decoupling between components. Hence, I will create a variable authWithFacebookObj assigning it as concrete implementation (higher module). This is know as dependency injection (you have a class that take interface as an parameter and you provide its concrete implementation at runtime or compile time).

Takeaway from Dependency Inversion Principle

The Dependency Inversion Principle states decoupling high-level modules (interfaces) from low-level modules (implementations) by introducing abstractions (such as interfaces or abstract classes) that define the interactions between them. By depending on abstractions rather than concrete implementations, software design become more flexible, extensible, and easier to maintain.

Summary

  1. Single Responsibility Principle:

    • Each unit of code (class, method, function) should have a single responsibility.

    • Promotes easier debugging and maintainability.

    • Example: Separate validation, registration, and email confirmation in an authentication module.

  2. Open/Closed Principle:

    • Software entities should be open for extension but closed for modification.

    • Encourages adding new functionalities without altering existing code.

    • Example: Extending authentication logic without modifying the original code.

  3. Liskov Substitution Principle:

    • Child classes should be exchangeable for their base (parent) classes.

    • Objects of a subclass should be able to replace objects of the superclass without affecting program correctness.

    • Encourages code reusability and extensibility.

    • Example: Extending authentication classes without changing their behavior.

  4. Interface Segregation Principle:

    • Interfaces should be tailored to specific objects and should not contain irrelevant functionalities.

    • Classes should only implement interfaces that are relevant to their functionality.

    • Promotes cleaner and more modular design.

    • Example: Defining separate interfaces for different authentication methods.

  5. Dependency Inversion Principle:

    • High-level modules should not depend on low-level modules. Both should depend on abstractions.

    • Abstractions should not depend on details; details should depend on abstractions.

    • Promotes decoupling, flexibility, and maintainability.

    • Example: Using interfaces to define interactions between modules and injecting concrete implementations at runtime or compile time.

Sourcecode

ย