The SOLID design principle

When writing software in the object oriented way, we have components that are in a way related to each other. If the software base grows, these relationships can become more complex each time code is added. The SOLID design patterns, defined by Robert C. Martin a.k.a. Uncle Bob, define a way to build up our components in such a way they can easily be extended and maintained. SOLID stands for:

  • Single responsibility principle
  • Open/closed principle
  • Liskov substitution principle
  • Interface segregation principle
  • Dependency inversion principle

We will go through these principles:

Single responsibility principle

“A class should have one, and only one, reason to change.”

If a class has more than one responsibility, it may impair that changing one responsibility will have effect on the other responsibility or responsibilities. It is not always easy to see the multiple responsibilities of a class, take for example an interface for playing audio files:

public interface AudioPlayer
{ 
  public void Initialize(AudioType audioType);
  public bool Play(string audioFile);
  public bool Stop();
}

You could identify two responsibilities here: initializing the audioplayer for a certain type of audio file. The second responsibility is start and stop playing of an audio file.

It may not be a problem to have these responsibilities tight together. Focussing on just one responsibility per class will make the class more robust. It makes it easier to maintain, since the place of the code in the class makes more sense. Also changing code in the play/stop part doesn’t affect the initialization when the code is less tangled and separated in separate classes.

Open/closed principle

“You should be able to extend a classes behavior, without modifying it.”

Modules that conform the open/closed principle have two primary attributes:

  1. They are open for extension;
  2. They are closed for modification.

So, it must be possible to extend a class, without changing the code. These two properties seems to be the opposite of each other. However this can be obtained by using abstract classes. Note that the SOLID design pattern uses the polymorfic open/closed principle, which uses the notion of abstract classes or interfaces. There is also Meyers open/closed principle which doesn’t have the notion of abstract classes or interfaces.

Take for example the audio player in our first example. We could define an interface AudioPlayer that looks like this:

public interface IAudioPlayer
{
   public void Initialize();
   public bool Play();
   public bool Stop();
}

Now, we could have a concrete Mp3AudioPlayer implementing the abstract methods:

public class Mp3AudioPlayer : IAudioPlayer
{
    public void Initialize() { ... }
    public bool Play() { ... }
    public bool Stop() { ... }
}

Let’s assume the following class which uses this abstraction:

public class Client
{
  public Client(IAudioPlayer audioPlayer);
}

In this case the Client uses implementations of the interface IAudioPlayer. Due to this, the client class can remain unchanged (so principle 2 closed for modification is met). Also the functionality of the IAudioPlayer can be extended by extending the interface (so principle 1 open for extension is also met).

Some design patterns that use the open/closed principle:

  • Decorator pattern;
  • Factory method pattern;
  • Observer pattern.

Liskov substitution principle

“Derived classes must be substitutable for their base classes.”

When you read the formal definition of the Liskov substitution principle, defined by Barbara Liskov, it may be hard to grasp at first. I will skip therefore this formal definition and will explain what it is.

When using the Liskov substitue, you have a class A and a derived one called class B. You can now replace class A by class B, where the method which uses the class B sees no difference between class A and class B. Say you have the interface in our example above:

public interface IAudioPlayer
{
   void Initialize();
   bool Play();
   bool Stop();
}

And two classes that implement this interface:

public class Mp3Player : IAudioPlayer { ... }

public class WavPlayer : IAudioPlayer { ... }

You can now easily replace within a class which uses the IAudioPlayer a Mp3Player by a WavPlayer. If you have a special kind of player, lets say TestPlayer which has different properties, it violates this rule. Example of the TestPlayer class:

public class TestPlayer 
{
  public void Test();
}

You can now create a Client and use casting to put the TestPlayer into the client:

TestPlayer t = new TestPlayer();
Client c = new Client(t as IAudioPlayer);
c.Play();

The play method of client looks like this:

 public void Play()
 {
    mAudioPlayer.Play();
 }

This will compile, but you now get a nullpointer exception during run-time when Play is called on the TestPlayer.

Interface segregation principle

Make fine grained interfaces that are client specific.

For use in the audio player, we have files that store the audio. Say we define a audio type that can contain meta data:

public interface IAudioFile
{
   string GetLocation();
   string GetMetaTag();
}

If another team want’s to reuse this interface to define a type of audio which doesn’t use meta data, they can implement like this:

public class FancyAudioFile : IAudioFile
{
   public string GetLocation()
   {
   }
   public string GetMetaTag()
   {
     throw new NotImplementedException();
   }
}

But what if the IAudioFile interface changes? Then the FancyAudioFile class will break and the other team will have to recompile it’s class. The interface segregation principle states that you shouldn’t depend a class from an interface if it doesn’t use it. If the interface changes, you also need to change the classes that implement them. By splitting large interfaces into smaller specific ones, a class can derive from the specific interfaces it only needs.

Dependency inversion principle

Depend on abstractions, not on concretions.

The definition of dependency inversion reads as follows:

  1. High level modules should not depend upon low level modules. Both should depend on abstractions.
  2. Abstractions should not depend on upon details. Details should depend on abstractions.

This means when you use a call within a high level class, you depend on the abstractions instead of low level modules. These low level modules are aware of the nity details that need to performed. Here abstractions do not depend on details, details should depend on the abstractions. I will make this clear by a little example.

Think for example the Play in our audio player example. When you call the play method, you know that it will play music. So in a higher level class you can call the lower level call “Play”. What if how music is played will change? The problem is when you depend within a higher class on low level modules, when you have to know much of the detail of the lower classes, when these lower classes change you have to change also the code in your higher class. By having a layer of abstraction between the lower and higher class, the lower class can change internally without having to change the higher class. So we will have a low level interface, which will be implemented by a class which has a real implementation to get the tiny details done:

public interface IMp3File
{
   bool OpenFile(string fileName);
   bool OpenStream();
   bool StartPlaying();
   bool CloseStream();
   bool StopPlaying();
}

Our higher level class, which doesn’t know how to play exactly the MP3 file will look like this:

public class Mp3AudioPlayer : IAudioPlayer
{
   private IMp3File m_mp3File;
   public void Initialize()
   {
       m_mp3File = null;
   }

   public bool Play(string fileName)
   {
      bool isPlaying = false;

      if (m_mp3File.OpenFile(fileName))
      {
         if (m_mp3File.OpenStream())
         {
            isPlaying = m_mp3File.StartPlaying();
         }
      }
      return isPlaying;
   }

   public bool Stop()
   {
     ...
   }
}

Dependency inversion is also used in the case of stubs in unit tests. You write a general call and the stub will implement the low level implementation. So how it’s done doesn’t matter for the higher class, only that it is done. When the stub internally changes it behavior, we don’t have to change the unit test. It also makes the high level classes more reusable, since they are not tightly connected to the lower ones.

Conclusion

The SOLID design patterns cover five principles that can help you to write classes that are easier to maintain and extend. It is a set of best practices, which you will see by a number of design patterns.

Posted in Best practices, Design patterns by Bruno at February 15th, 2017.
Tags:

Leave a Reply