rss

Lissel's code blogShort rambles about programming


The Single Responsibility Principle explained with lego

Never do more than one thing

tl;dr

The single responsibility principle states that a class should never do more than one thing.

Don't do this:

The ice-cream program

Say we are trying to build an ice-cream program. Exactly what it does is not important, but it will need some logging, database connectivity and stream reading.

Let us define an interface that our program can use for the stream reading:

interface IStreamReader {
    IceCreamData[] ReadFromStream(string fileName)
}

Implementing the stream reader

Now, we'll try to implement the IStreamReader:

public class IceCreamStreamReader : IStreamReader {
    public IceCreamData[] ReadFromStream(string fileName) {
        // --- Open stream ---
        var executablePath = Assembly.GetEntryAssembly().Location;
        var filePath = Path.Combine(fileName, executablePath)
        var stream = File.OpenRead(filePath)

        // --- Read from stream ---
        IceCreamData[] iceCreams = new IceCreamData[] {};

        var array = new byte[300];
        var span = new Span<byte>(array);
        while(stream.Read(span,0,span.length) > 0) {
            iceCreams.push(ParseAsIceCream(span));
        }

        // --- Validate data ----
        foreach(var iceCream in iceCreams) {
          if (iceCream.Size > 5 || iceCream.Size < 1) {
            throw new Exception("Invalid ice cream size");
          }
          if (!PossibleTastes.Contains(iceCream.Taste)) {
            throw new Exception("Unknown taste");
          }
          if (iceCream.Payment != 0) {
              throw new Exception("Ice cream payment should be 0")
          }
        }
    }

    private IceCreamData ParseAsIceCream(Span<byte> span) {
        // A b*ttload of parsing code
        // ...
    }
}

This is a bit of a wall of text. Did this hurt your eyes/brain to read? Did you notice the missing return at the end?

Worse, the class would grow even clumsier if you would need to:

The Single Responsibility Principle

The root of the issue is that our IceCreamStreamReader has too many responsibilities. It is supposed to:

I.e. we thought we were implementing a single nice lego brick:

But in fact this is what we did. Yuck:

Allow me to reiterate the Single Responsiblity Principle:

Each class should have only one reason for existing, one responsibility.

Splitting into smaller classes

Having identified all the things that our class does, let us break it into separate components:

/**
    Responsible for chosing which components to use
    (different streamOpeners, parsers, etc.)
    -
    AND responsible for running the entire flow.
*/
public class IceCreamStreamReader : IStreamReader {
    public IceCreamData[] ReadFromStream(string fileName) {
        const streamOpener = new FileStreamOpener(fileName);
        const parser = new StreamV1Parser();
        const validator = new IceCreamValidator();

        const stream = streamOpener.OpenStream();

        const iceCreams = parser.Parse(stream);

        foreach(var iceCream in iceCreams) {
            const errorMessage = validator.Validate(iceCream);
            if (errorMessage != "") {
                throw new Exception(errorMessage);
            }
        }

        return iceCreams;
    }
}
public class FileStreamOpener {
    private string fileName;

    public FileStreamOpener(string fileName) {
        this.fileName = fileName;
    }

    public Stream OpenStream() {
        var executablePath = Assembly.GetEntryAssembly().Location;
        var filePath = Path.Combine(fileName, executablePath)
        var stream = File.OpenRead(filePath)
    }
}
public class StreamV1Parser {
    public IceCreamData[] Parse(Stream stream) {
        IceCreamData[] iceCreams = new IceCreamData[] {}; 

        var array = new byte[300];
        var span = new Span<byte>(array);
        while(stream.Read(span,0,span.length) > 0) {
            iceCreams.push(ParseAsIceCream(span));
        }

        return iceCreams;
    }

    private IceCreamData ParseAsIceCream(Span<byte> span) {
        // A b*ttload of parsing code
    }
}
public class IceCreamValidator {
    public string Validate(IceCreamData iceCream) {
        if (iceCream.Size > 5 || iceCream.Size < 1) {
            return "Invalid ice cream size";
        }

        if (!PossibleTastes.Contains(iceCream.Taste)) {
            return "Unknown taste";
        }

        if (iceCream.Payment != 0) {
            return "Ice cream payment should be 0";
        }

        return "";
    }
}

Notice how we moved all the low-level code away from IceCreamStreamReader. This makes it easier to reason about the flow of data in the program.

Going further

Did you notice the "AND" in the comment for the IceCreamStreamReader? It seems like it has two different responsibilities!

First let's make interfaces for our new building blocks:

interface IStreamOpener {
    public Stream OpenStream();
}

interface IStreamParser {
    public IceCreamData[] Parse(Stream stream);
}

interface IIceCreamValidator {
    public string Validate(IceCreamData iceCream);
}
public class FileStreamOpener : IStreamOpener {
    // ... as before ...
}
public class StreamV1Parser : IStreamParser {
    // ... as before ...
}
public class IceCreamValidator : IIceCreamValidator {
    // ... as before ...
}

Now we can split the main class into its two different responsiblities:

/**
    Responsible for chosing which components to use
    (different streamOpeners, parsers, etc.)
*/
public class IceCreamStreamReader : IStreamReader {
    public IceCreamData[] ReadFromStream(string fileName) {
        const streamOpener = new FileStreamOpener(fileName);
        const parser = new StreamV1Parser();
        const validator = new IceCreamValidator();

        const iceCreams = StreamReaderRunner.Run(streamOpener, parser, validator);
        return iceCreams;
    }
}
/**
    Responsible for running the entire flow: opening, parsing, and validating.
*/
public static class StreamReaderRunner {
    public static IceCreamData[] Run(IStreamOpener opener, IStreamParser parser, IIceCreamValidator validator) {
        const stream = opener.OpenStream();

        const iceCreams = parser.Parse(stream);

        foreach(var iceCream in iceCreams) {
            const errorMessage = validator.Validate(iceCream);
            if (errorMessage != "") {
                throw new Exception(errorMessage);
            }
        }

        return iceCreams;
    }
}

Closing notes

In this case it might be reasonable to ask: is it really up to the IceCreamStreamReader to decide upon validation? Or from which network port to load the stream?

Maybe this decision belongs inside the program's code. In this case the interface between the program and the stream reader is no longer correct, and should be expanded.