SOLID Design Principles

SOLID is an acronym comprised of 5 basic principles intended to create clean, maintainable and simple software architecture.

Single Responsibility Principle:

  • “Every software module should have only one reason to change”, meaning each class should have one distinct purpose and only contain code for that exact purpose.
  • For example: An EnemyDefinition class may contain the data/state of an enemy, which will eventually be saved. The save code doesn’t belong in the EnemyDefinition. The EnemyDefinition may however contain Serialise and Deserialise methods as they pertain to the way the data is interpreted but the EnemyDefinition should not care how the data is saved, whether that be on disk, sent to a server etc.
  • In the example below, thanks to the single responsibility principle, we could change our system to upload and download data from a server without needing to touch a line of code in the EnemyDefinition. Since that is the job of the DefinitionSaveLoad, that is the only class we would need to change (or swap out using polymorphism).
using System;
using System.IO;
using System.Collections.Generic;

class MainClass
{
    public static void Main (string[] args)
    {
        EnemyDefinition enemy1 = new EnemyDefinition();
        enemy1.name ="Frank";
        enemy1.damage   = 5;
        enemy1.health   = 300;

        EnemyDefinition enemy2 = new EnemyDefinition();
        enemy2.name = "Bob";
        enemy2.damage   = 2;
        enemy2.health   = 150;

        Console.WriteLine("Initialised");
        Console.WriteLine(enemy1.ToString());
        Console.WriteLine(enemy2.ToString());

        DefinitionSaveLoad saveLoad = new DefinitionSaveLoad();
        saveLoad.AddSaveData(enemy1.Serialise());
        saveLoad.AddSaveData(enemy2.Serialise());
        saveLoad.Save();
        Console.WriteLine("\n Saved");

        enemy1.name = "";
        enemy1.damage   = 0;
        enemy1.health   = 0;

        enemy2.name = "";
        enemy2.damage   = 0;
        enemy2.health   = 0;

        Console.WriteLine("\nData Cleared");
        Console.WriteLine(enemy1.ToString());
        Console.WriteLine(enemy2.ToString());

        string[] loadData = saveLoad.Load();
        enemy1.Deserialise(loadData[0]);
        enemy2.Deserialise(loadData[1]);

        Console.WriteLine("\nData Loaded:");
        Console.WriteLine(enemy1.ToString());
        Console.WriteLine(enemy2.ToString());
    }
}

class EnemyDefinition
{
    public string name;
    public int damage;
    public int health;

    public override string ToString ()
    {
        return Serialise();
    }

    public string Serialise()
    {
        return name
            + "," + damage
            + "," + health;
    }

    public void Deserialise(string serialisedData)
    {
        string[] data   = serialisedData.Split(',');
        name        = data[0];
        damage      = int.Parse(data[1]);
        health      = int.Parse(data[2]);
    }
}

class DefinitionSaveLoad
{
    List<string> _saveData = new List<string>();
    string FilePath
    {
        get
        {
            return Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) + "/saveFile.txt";
        }
    }

    public void AddSaveData(string data)
    {
        _saveData.Add(data);
    }

    public void Save()
    {
        if(File.Exists(FilePath))
        {
            File.Delete(FilePath);
        }

        File.WriteAllLines(FilePath, _saveData);
    }

    public string[] Load()
    {
        return File.ReadAllLines(FilePath);
    }
}

Open Closed Principle:

  • “A software module/class is open for extension and closed for modification” which means that one should never need to modify a base class to cater to the needs of a derived class.
  • Using inheritance and method overrides is a way to overcome this.
  • The example below illustrates a problem. What happens when one wants to add a new EnemyType, say a SWIMMING type? They’d have to modify the EnemyDefinition to add new code so GetDifficultyRating can cater for the new SWIMMING type too:
using System;

class MainClass
{
    public static void Main (string[] args)
    {
        EnemyDefinition enemy1 = new EnemyDefinition();
        enemy1.name     = "Walker Frank";
        enemy1.damage   = 5;
        enemy1.health   = 300;
        enemy1.type     = EnemyType.WALKING;

        EnemyDefinition enemy2 = new EnemyDefinition();
        enemy2.name     = "Flyer Bob";
        enemy2.damage   = 2;
        enemy2.health   = 150;
        enemy2.type     = EnemyType.FLYING;

        Console.WriteLine(enemy1.name + "'s difficulty rating is: " + enemy1.GetDifficultyRating());
        Console.WriteLine(enemy2.name + "'s difficulty rating is: " + enemy2.GetDifficultyRating());
    }
}

class EnemyDefinition
{
    public string name;
    public int damage;
    public int health;
    public EnemyType type;

    public int GetDifficultyRating()
    {
        int difficultyRating = 0;

        if(type == EnemyType.WALKING)
        {
            difficultyRating = (damage + health) * 2;
        }
        else if(type == EnemyType.FLYING)
        {
            difficultyRating = (damage + health) * 10;
        }   

        return difficultyRating;
    }
}

public enum EnemyType
{
    FLYING,
    WALKING,
}
  • Also think about the repercussions of modifying a method that could be shared by many enemy types. If someone somehow “broke” the difficulty rating calculation, there’s potential for all enemys’ difficulty rating calculations breaking since they’re all sharing the exact same code. Each modification has the potential to introduce a bug across all the types of enemies.
  • A solution to this issue would be to have the EnemyDefinition as an extensible base class, and override the GetDifficultyRating method in each of the derived classes:
using System;

class MainClass
{
    public static void Main (string[] args)
    {
        WalkingEnemyDefinition enemy1 = new WalkingEnemyDefinition();
        enemy1.name     = "Walker Frank";
        enemy1.damage   = 5;
        enemy1.health   = 300;

        FlyingEnemyDefinition enemy2 = new FlyingEnemyDefinition();
        enemy2.name     = "Flyer Bob";
        enemy2.damage   = 2;
        enemy2.health   = 150;

        FlyingEnemyDefinition enemy3 = new FlyingEnemyDefinition();
        enemy3.name     = "Swimmer Jane";
        enemy3.damage   = 10;
        enemy3.health   = 200;

        Console.WriteLine(enemy1.name + "'s difficulty rating is: " + enemy1.GetDifficultyRating());
        Console.WriteLine(enemy2.name + "'s difficulty rating is: " + enemy2.GetDifficultyRating());
        Console.WriteLine(enemy3.name + "'s difficulty rating is: " + enemy3.GetDifficultyRating());
    }
}

class EnemyDefinition
{
    public string name;
    public int damage;
    public int health;

    public virtual int GetDifficultyRating()
    {
        return damage + health;
    }
}

class WalkingEnemyDefinition : EnemyDefinition
{
    public override int GetDifficultyRating()
    {
        return (damage + health) * 2;
    }
}

class FlyingEnemyDefinition : EnemyDefinition
{
    public override int GetDifficultyRating()
    {
        return (damage + health) * 10;
    }
}

class SwimmingEnemyDefinition : EnemyDefinition
{
    public override int GetDifficultyRating()
    {
        return (damage + health) * 5;
    }
}
  • And there you have it. We can now create as many different enemy types with different difficulty rating calculations without needing to touch a single line of code in the EnemyDefinition.

Liskov Substitution Principle:

  • Also known as (LSP) – not to be confused with the Lumpy Space Princess from the popular TV series “Adventure Time”.
  • “You should be able to use any derived class instead of a parent class and have it behave in the same manner without modification”, which means that child classes should extend the functionality of their parents, not change it completely.
  • In the following code, the issue is that there is StatueEnemyDefinition type. In this case Statue Enemy types don’t have a difficulty rating and one needs to remember not to call the GetDifficultyRating method on Statue Enemy types or there will be an exception thrown:
using System;
using System.Collections.Generic;

class MainClass
{
    public static void Main (string[] args)
    {
        List<EnemyDefinition> enemies = new List<EnemyDefinition>();

        EnemyDefinition enemy1 = new WalkingEnemyDefinition();
        enemy1.name     = "Walker Frank";
        enemy1.damage   = 5;
        enemy1.health   = 300;
        enemies.Add(enemy1);

        EnemyDefinition enemy2 = new FlyingEnemyDefinition();
        enemy2.name = "Flyer Bob";
        enemy2.damage   = 2;
        enemy2.health   = 150;
        enemies.Add(enemy2);

        EnemyDefinition enemy3 = new StatueEnemyDefinition();
        enemy3.name = "Statue Jane";
        enemy3.damage   = 0;
        enemy3.health   = 0;
        enemies.Add(enemy3);

        foreach(EnemyDefinition enemy in enemies)
        {
            Console.WriteLine(enemy.name + "'s difficulty rating is: " + enemy.GetDifficultyRating());
        }
    }
}

class EnemyDefinition
{
    public string name;
    public int damage;
    public int health;

    public virtual int GetDifficultyRating()
    {
        return damage + health;
    }
}

class WalkingEnemyDefinition : EnemyDefinition
{
    public override int GetDifficultyRating()
    {
        return (damage + health) * 2;
    }
}

class FlyingEnemyDefinition : EnemyDefinition
{
    public override int GetDifficultyRating()
    {
        return (damage + health) * 10;
    }
}

class StatueEnemyDefinition : EnemyDefinition
{
    public override int GetDifficultyRating()
    {
        throw new Exception("Statue Enemies don't have a difficulty!");
    }
}
  • This is dangerous and a really bad design as anyone using this code will always have to remember not to call the method on the Statue Enemy type.
  • In the code below, we’ve corrected this design flaw.
    • Firstly, there is no need for statue enemy types to have “health” or “damage” fields as they have no health and do no damage, so I’ve changed the design so the EnemyDefinition contains only the fields shared by ALL enemies.
    • The attributes specific to Attackable Enemies have been moved to an attackable enemy class.
    • With the latest set of modifications, each type is substitutable for its parent and behaves as expected when treated as such – all the classes with the GetDifficultyRating method actually require and implement it:
using System;
using System.Collections.Generic;

class MainClass
{
    public static void Main (string[] args)
    {
        List<EnemyDefinition> enemies = new List<EnemyDefinition>();
        List<AttackableEnemyDefinition> attackableEnemies = new List<AttackableEnemyDefinition>();

        AttackableEnemyDefinition enemy1 = new WalkingEnemyDefinition();
        enemy1.name = "Walker Frank";
        enemy1.damage   = 5;
        enemy1.health   = 300;
        enemies.Add(enemy1);
        attackableEnemies.Add(enemy1);

        AttackableEnemyDefinition enemy2 = new FlyingEnemyDefinition();
        enemy2.name = "Flyer Bob";
        enemy2.damage   = 2;
        enemy2.health   = 150;
        enemies.Add(enemy2);
        attackableEnemies.Add(enemy2);

        EnemyDefinition enemy3 = new EnemyDefinition();
        enemy3.name     = "Statue Jane";
        enemies.Add(enemy3);

        Console.WriteLine("All Enemies");
        foreach(EnemyDefinition enemy in enemies)
        {
            Console.WriteLine(enemy.name);
        }

        Console.WriteLine("\nAttackable Enemies");
        foreach(AttackableEnemyDefinition enemy in attackableEnemies)
        {
            Console.WriteLine(enemy.name + "'s difficulty rating is: " + enemy.GetDifficultyRating());
        }
    }
}

class EnemyDefinition
{
    public string name;
}

class AttackableEnemyDefinition : EnemyDefinition
{
    public int damage;
    public int health;

    public virtual int GetDifficultyRating()
    {
        return damage + health;
    }
}

class WalkingEnemyDefinition : AttackableEnemyDefinition
{
    public override int GetDifficultyRating()
    {
        return (damage + health) * 2;
    }
}

class FlyingEnemyDefinition : AttackableEnemyDefinition
{
    public override int GetDifficultyRating()
    {
        return (damage + health) * 10;
    }
}

Interface Segregation Principle:

  • “Clients should not be forced to implement interfaces they don’t use. Instead of one fat interface, many small interfaces are preferred based on groups of methods, each one serving one sub module.”
  • Which basically means to avoid being forced to implement code that won’t be used by the inheriting class.
  • In the example below, the Statue Enemy is forced to implement the Save and Load methods because they are part of the base class, even though – for the sake of this example – statue enemies don’t need to be saved or loaded:
using System;
using System.Collections.Generic;

class MainClass
{
    public static void Main (string[] args)
    {
        List<EnemyDefinition> enemies = new List<EnemyDefinition>();

        AttackableEnemyDefinition enemy1 = new AttackableEnemyDefinition();
        enemy1.name     = "Walker Frank";
        enemies.Add(enemy1);

        AttackableEnemyDefinition enemy2 = new AttackableEnemyDefinition();
        enemy2.name     = "Flyer Bob";
        enemies.Add(enemy2);

        EnemyDefinition enemy3 = new StatueEnemyDefinition();
        enemy3.name     = "Statue Jane";
        enemies.Add(enemy3);

        Console.WriteLine("All Enemies");
        foreach(EnemyDefinition enemy in enemies)
        {
            Console.WriteLine(enemy.name);
        }

        Console.WriteLine(" \nSaving Enemies");
        foreach(EnemyDefinition enemy in enemies)
        {
            Console.Write(enemy.name + ": ");
            enemy.Save();
        }
    }
}

abstract class EnemyDefinition
{
    public string name;
    public abstract void Save();
    public abstract void Load();
}

class AttackableEnemyDefinition : EnemyDefinition
{
    public override void Save()
    {
        Console.WriteLine("Saving attackable enemies.");
    }

    public override void Load()
    {
        Console.WriteLine("Loading attackable enemies.");
    }
}

class StatueEnemyDefinition : EnemyDefinition
{
    public override void Save()
    {
        Console.WriteLine("Not saving statues.");
    }

    public override void Load()
    {
        Console.WriteLine("Not loading statues.");
    }
}
  • In the modified code, the EnemyDefinition has had the Save and Load code removed as not all enemies need to be saved and loaded (the Statue enemy in this case).
  • Instead, the Save and Load has been placed in an interface that can be implemented by any entity that requires the ability to be saved and loaded. In this way, we avoid having unused code in our classes:
using System;
using System.Collections.Generic;

class MainClass
{
    public static void Main (string[] args)
    {
        List<EnemyDefinition> enemies = new List<EnemyDefinition>();
        List<ISaveLoad> saveLoadable = new List<ISaveLoad>();

        AttackableEnemyDefinition enemy1 = new AttackableEnemyDefinition();
        enemy1.name     = "Walker Frank";
        enemies.Add(enemy1);
        saveLoadable.Add(enemy1);

        AttackableEnemyDefinition enemy2 = new AttackableEnemyDefinition();
        enemy2.name     = "Flyer Bob";
        enemies.Add(enemy2);
        saveLoadable.Add(enemy2);

        EnemyDefinition enemy3 = new StatueEnemyDefinition();
        enemy3.name     = "Statue Jane";
        enemies.Add(enemy3);

        Console.WriteLine("All Enemies");
        foreach(EnemyDefinition enemy in enemies)
        {
            Console.WriteLine(enemy.name);
        }

        Console.WriteLine(" \nSaving");
        foreach(ISaveLoad saveLoadableEntry in saveLoadable)
        {
            saveLoadableEntry.Save();
        }
    }
}

abstract class EnemyDefinition
{
    public string name;
}

interface ISaveLoad
{
    void Save();
    void Load();
}

class AttackableEnemyDefinition : EnemyDefinition, ISaveLoad
{
    public void Save()
    {
        Console.WriteLine("Saving " + name);
    }

    public void Load()
    {
        Console.WriteLine("Loading " + name);
    }
}

class StatueEnemyDefinition : EnemyDefinition
{
}

Dependency Inversion Principle

  • “High-level modules/classes implement business rules or logic in a system/application. Low-level modules/classes deal with more detailed operations.” meaning that high-level classes shouldn’t be concerned with the nitty gritty details of writing files to a specific file system etc. This principle serves to help abstraction.
  • In the code below, what happens when we want to disable logging to the console? Or what if we want to display the text on screen instead? Or send it to a server for processing?
using System;

class MainClass
{
    public static void Main (string[] args)
    {
        EnemyDefinition enemy1 = new EnemyDefinition();
        enemy1.SetName("Frank");

        EnemyDefinition enemy2 = new EnemyDefinition();
        enemy2.SetName("Bob");
    }
}

class EnemyDefinition
{
    string _name;

    public void SetName(string name)
    {
        _name = name;
        Console.WriteLine("Setting name to: " + name);
    }
}
  • If we wanted to make any changes to the way logging worked, we’d have to go through potentially many classes, modifying the code to change the Console.WriteLine to whatever else.
  • This is where Dependency Inversion comes in handy. Using DI, we can wrap up the nitty gritty of the logging functionality, allowing the EnemyDefinition and its derived classes to continue doing their thing without needing to do the actual nitty gritty logging to the console themselves:
using System;

class MainClass
{
    public static void Main (string[] args)
    {
        Logger logger = new Logger();

        EnemyDefinition enemy1 = new EnemyDefinition(logger);
        enemy1.SetName("Frank");

        EnemyDefinition enemy2 = new EnemyDefinition(logger);
        enemy2.SetName("Bob");
    }
}

class EnemyDefinition
{
    Logger _logger;
    string _name;

    public EnemyDefinition(Logger logger)
    {
        _logger = logger;
    }

    public void SetName(string name)
    {
        _name = name;
        _logger.Log("Setting name to: " + name);
    }
}

class Logger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}
  • So now if we want to disable logging, we only need to make one change in one place in our code (the Log method of the Logger class).
  • We could also have different Logger implementations, possibly one for the console and another that logs to a file:
using System;
using System.IO;

class MainClass
{
    public static void Main (string[] args)
    {
        ILogger logger = new FileLogger();

        EnemyDefinition enemy1 = new EnemyDefinition(logger);
        enemy1.SetName("Frank");

        EnemyDefinition enemy2 = new EnemyDefinition(logger);
        enemy2.SetName("Bob");
    }
}

class EnemyDefinition
{
    ILogger _logger;
    string _name;

    public EnemyDefinition(ILogger logger)
    {
        _logger = logger;
    }

    public void SetName(string name)
    {
        _name = name;
        _logger.Log("Setting name to: " + name);
    }
}

interface ILogger
{
    void Log(string message);
}

class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

class FileLogger : ILogger
{
    string LogPath
    {
        get
        {
            return Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) + "/log.txt";
        }
    }

    public void Log(string message)
    {
        if(!File.Exists(LogPath))
        {
            File.WriteAllText(LogPath, message + "\n");
        }
        else
        {
            File.AppendAllText(LogPath, message + "\n");
        }
        Console.WriteLine(LogPath);
    }
}
  • As you can see, it is now pretty simple to swap out the ConsoleLogger for a FileLogger, or whatever other type of logger we decide to create, with the best part being that not a single line of code in the EnemyDefinition needs to change!

Sources: