SOLID is an acronym for 5 important OOP (Object Oriented Programming) design principles. These five principles were introduced in his 2000 paper Design Principles and Design Patterns by Robert C. Martin (Uncle Bob).
However, Michael Feathers later identified the actual SOLID acronym.
These principles are intended to make software designs more comprehensible, easier to maintain and easier to extend. These five principles are essential to know as a software engineer! I will cover these principles in this article, giving examples of how they are violated and how they can be corrected in order to comply with SOLID.
Examples are given in C #, but they apply to any language based on OOP.
SOLID Principles
- S – Single responsibility principle
- O – Open and closed principle
- L – Liskov substitution principle
- I – Interface segregation principle
- D – Dependency inversion principle
1 – Single responsibility principle
The Single Responsibility Principle stipulates that each module or class should be accountable for a single part of the software’s functionality.
You might have heard the quote: “Take one thing and do it right.” This refers to the principle of one responsibility. Robert C. Martin defines a responsibility as a’ reason to change’ in the article Principles of Object Oriented Design, concluding that a class or module should have one and only one reason to change.
Let’s give an example of how to write a piece of code in violation of this principle.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class UserModel { void CreatePost(Database dbObject, string Message) { try { dbObject.Add(Message); } catch (Exception e) { dbObject.LogError("An error occured: ", e.ToString()); File.WriteAllText("\Errors.txt", e.ToString()); } } } |
We notice how much responsibility lies with the CreatePost method, since it can both create a new post, log an error in the database and log an error in a local file. This violates the principle of one responsibility.
Let’s try to make it right.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class Post { private ErrorLogger errorLogger = new ErrorLogger(); void CreatePost(Database dbObject, string Message) { try { dbObject.Add(Message); } catch (Exception e) { errorLogger.log(e.ToString()) } } } class ErrorLogger { void log(string error) { db.LogError("An error occured: ", error); File.WriteAllText("\Errors.txt", error); } } |
By abstracting the functionality that handles the logging of errors, we no longer infringe the principle of single responsibility.
We now have two classes each with one responsibility ; creating a post and logging an error, respectively.
2 – Open and closed principle
The open / closed principle states in programming that software entities (classes, modules, functions, etc.) should be open to extensions, but should be closed for modification. You probably already know about polymorphism if you have a general understanding of OOP.
By using inheritance and/or implementing interfaces that enable classes to polymorphically replace each other, we can ensure that our code complies with the open / closed principle.
This may sound confusing, so let’s take an example to make what I mean very clear.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Post { void CreatePost(Database dbObject, string Message) { if (Message.StartsWith("#")) { dbObject.AddAsTag(Message); } else { dbObject.Add(Message); } } } |
We need to do something specific in this code snippet whenever a post starts with the’ #’ character.
However, the above implementation violates the open / closed principle in the way the behavior on the starting letter is differentiated by this code.
If we were to include mentions starting with’ @’ later on, we would have to modify the class in the CreatePost method with an extra’ else if.’
Let’s try by simply using inheritance to make this code compliant with the open / closed principle.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Post { void CreatePost(Database dbObject, string Message) { dbObject.Add(Message); } } class TagPost : Post { override void CreatePost(Database dbObject, string Message) { dbObject.AddAsTag(Message); } } |
By using inheritance, by overriding the CreatePost method, it is now much easier to create extended behavior to the post object. The first character’ #’ evaluation will now be handled elsewhere (probably higher level) in our software, and the cool thing is that if we want to change the way a “Message” is evaluated, we can change the code there without affecting any of these underlying behaviors.
3 – Liskov substitution principle
Probably this one is the hardest to wrap your head around when first introduced. The Liskov substitution principle states in programming that if S is a T subtype, then Type T objects can be replaced with Type S objects.
This can be mathematically formulated as:
et ϕ(x) a property provable about objects x of type T.
Then ϕ(y) be true for objects y of type S and where S is a subtype of T.
More generally, it states that objects in a program should be replaceable without altering the correctness of that program with instances of their sub-types.
Let’s look at an example of how this principle can be violated.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
class Post { void CreatePost(Database dbObject, string Message) { dbObject.Add(Message); } } class TagPost : Post { override void CreatePost(Database dbObject, string Message) { dbObject.AddAsTag(Message); } } class MentionPost : Post { void CreateMentionPost(Database dbObject, string Message) { string user = Message.parseUser(); dbObject.NotifyUser(user); dbObject.OverrideExistingMention(user, Message); base.CreatePost(dbObject, Message); } } class PostHandler { private database = new Database(); void HandleNewPosts() { List<string> newPosts = database.getUnhandledPostsMessages(); foreach (string Message in newPosts) { Post post; if (Message.StartsWith("#")) { post = new TagPost(); } else if (Message.StartsWith("@")) { post = new MentionPost(); } else { post = new Post(); } post.CreatePost(database, Message); } } } |
Note how the CreatePost method call will not do what it is supposed to do in the case of a MentionPost subtype; notify the user and override existing mention. Since the CreatePost method in MentionPost is not overridden, the CreatePost method call in the class hierarchy will simply be delegated upwards and the CreatePost method from its parent class is called.
Correct this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class MentionPost : Post { override void CreatePost(Database dbObject, string Message) { string user = Message.parseUser(); NotifyUser(user); OverrideExistingMention(user, Message) base.CreatePost(dbObject, Message); } private void NotifyUser(string user) { dbObject.NotifyUser(user); } private void OverrideExistingMention(string user, string Message) { dbObject.OverrideExistingMention(_user, Message); } } |
We no longer violate the Liskov substitution principle by refactoring the MentionPost class so that we override the CreatePost) (method instead of calling it on its base class. This is but a simple example of how to correct a violation of this principle, but it can manifest this situation in a wide variety of ways and is not always easy to identify.
4 – Interface segregation principle
This principle can be quite easily understood. Indeed, if you’re used to using interfaces, you’re likely to apply this principle already. If not, it’s time to get started! The principle of interface segregation states in programming that no client should be forced to rely on methods that it does not use.
Simply put: Do not add additional functionality by adding new methods to an existing interface. Instead, create a new interface and, if necessary, allow your class to implement multiple interfaces. Let’s look at an example of how to break the principle of interface segregation.
1 2 3 4 5 6 7 8 9 10 |
interface IPost { void CreatePost(); } interface IPostNew { void CreatePost(); void ReadPost(); } |
Let’s pretend in this example that I first have an IPost interface with a CreatePost method signature. Later, by adding a new ReadPost method, I modify this interface, so it becomes like the IPostNew interface. This is where we are in violation of the principle of interface segregation. Rather, just create a new interface.
1 2 3 4 5 6 7 8 9 |
interface IPostCreate { void CreatePost(); } interface IPostRead { void ReadPost(); } |
If any class needs both the method of CreatePost method and the method of ReadPost, both interfaces will be implemented.
5 – Dependency inversion principle
We have finally reached D, the last of the five principles. The principle of dependency inversion is a way to decouple software modules in programming.
- High – level modules are not supposed to depend on low – level modules. Both should be abstracted.
- Abstractions are not supposed to depend on details. Details should be subject to abstractions.
In order to comply with this principle, we need to use a pattern of design known as a pattern of dependency inversion, most often solved by using dependency injection.
Injection of dependence is a huge topic and can be as complicated or as simple as one might see the need. Typically, injection of dependency is used simply by injecting any dependencies of a class as an input parameter through the constructor of the class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Post { private ErrorLogger errorLogger = new ErrorLogger(); void CreatePost(Database dbObject, string Message) { try { dbObject.Add(Message); } catch (Exception e) { errorLogger.log(e.ToString()) } } } |
Observe how in the Post class we create the ErrorLogger instance. This is a violation of the principle of reversal of dependency. We would have to modify the Post class if we wanted to use a different type of logger.
Let’s use dependency injection to fix this.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Post { private Logger _logger; public Post(Logger injectedLogger) { _logger = injectedLogger; } void CreatePost(Database dbObject, string Message) { try { dbObject.Add(Message); } catch (Exception e) { logger.log(e.ToString()) } } } |
We no longer rely on the Post class to define the specific type of logger by using dependency injection.
By applying these 5 principles that make up the SOLID acronym, we can take advantage of a reusable, maintainable, scalable and easy to test codebase. These are five basic principles used by professional software engineers around the globe, and if you’re serious about creating solid software, you should start applying these principles now!