Ahh SOLID principles. One of those things you most often here and have a good understanding but to be sure we will dissect them one by one and see with code samples how they help us out. At the end of the article you will not only be able to answer the question in your technical interview, but also why we do have those SOLID principles. Before we start with anything, let's begin with: What does SOLID stand for? - Single responsibility Principle
- Open-Close Principle
- Liskov Substitution Principle
- Interface segragation Principle
- Dependency Inversion Principle
And as the word principle suggest, this is nothing we can just implement like a template. This more abstract like design patterns. MotivationThe reason we have those principles is to make our code: - More readable
- More maintainable
- More flexible
You could say to be more "agile" 😉. Single responsibility Principle Gather together the things that change for the same reasons. Separate things that change for different reasons. Source The gist is: A class should have only one responsibility and one responsibility only. Let's see a counter-example first: public class LoginService
{
public class LoginServce(SmtpClient smptClient, Generator generator)
public void Login(User user) { ... }
public void Register(string username, string password) { ... }
public bool IsValidEmail(string mail) { ... }
public string GenerateUserReport(User user) { ... }
}
What is the problem here? Well our LoginService basically does multiple things: - Login an user
- Register a user
- Validate if an e-mail is valoid
- Generating a report for the user
Now why is this a problem? The issue is, that because of these dependencies it is hard for an outside person to understand when and if to use this class. It is hard to decide where to put new functionality. And also: the more stuff you add, the more entangled everything gets. Now your LoginService has 2 dependencies, but that can spiral out of control in no time when you add new responsibilities. That will also degrade your testability and therefore maintainability. So let's fix it. Just have 3 classes for the 3 responsibilities and let the caller decide when to do what. public class LoginService
{
public class LoginService()
public void Login(User user) { ... }
}
public class RegisterService
{
public void RegisterUser(string username, string password) { ... }
}
public class EmailValidator
{
public bool IsValidEmail(string mail) { ... }
}
public class UserReportGenerator
{
public UserReportGenerator(Generator generator) {}
public string GenerateUserReport(User user) { ... }
}
Open-Close principle A Module should be open for extension but closed for modification. Source Modules/classes should be open for extension, but close to modifications. If you want to put it simple: If you want to add new features, we want to avoid to edit / modify our current source code. Let's have the counter example first: public class Rectangle
{
public int Height { get; set; }
public int Width { get; set; }
}
public class Circle
{
public int Radius { get; set; }
}
Now there are two ways, which are equally bad. Let's take the first: public class AreaCalculator
{
public int GetArea(Rectangle rect) => rect.Width * rect.Height;
public int GetArea(Circle circle) => 2 * Math.PI * circle.Radius;
}
If we would add add another shape let's say Triangle would have to create another method GetArea(Triangle triangle) . Now you can also go with the approach to take an object and check which type it is:
public class AreaCalculator
{
public int GetArea(object shape)
{
if (shape is Rectangle rect) { ... }
if (shape is Circle circle) { ... }
But the same problem here, we would have to change the class itself to add the new shape. The easy way out here is to to have an abstract class which defines a GetArea method and every object which inherits from this class has to implement this method in the appropriate way: public abstract class Shape
{
public abstract int GetArea();
}
public class Rectangle : Shape
{
public int Height { get; set; }
public int Width { get; set; }
public override int GetArea() => Height * Width;
}
public class Circle : Shape
{
public int Radius { get; set; }
public override int GetArea() => 2* Math.PI * Radius;
}
Shape someShape = new Rectangle();
var area = someShape.GetArea();
Adding a new shape does not interfer with any object we already created. If we add a triangle we just add another class which has to implement the GetArea method. One word of caution here: Inheritance comes with trade-offs: Mainly you couple your objects plus you have a level of abstraction which makes understanding your code more difficult. We also will see the Liskov Substitution Principle which plays a major role here. We can also use composition instead of inheritance to ensure the Open-Close Principle. A good example I had some month ago in my blog: The decorator pattern. If we have a repository which connects to an external source like this: public class SlowRepository : IRepository
{
private readonly List _people = new();
public async Task GetPersonByIdAsync(int id)
{
await Task.Delay(1000);
return _people.Single(p => p.Id == id);
}
public Task SavePersonAsync(Person person)
{
_people.Add(person);
return Task.CompletedTask;
}
}
We can extend the functionality without touching any of the code itself: public class CachedRepository : IRepository
{
private readonly IMemoryCache _memoryCache;
private readonly IRepository _repository;
public CachedRepository(IMemoryCache memoryCache, IRepository repository)
{
_memoryCache = memoryCache;
_repository = repository;
}
public async Task GetPersonByIdAsync(int id)
{
if (!_memoryCache.TryGetValue(id, out Person value))
{
value = await _repository.GetPersonByIdAsync(id);
_memoryCache.Set(id, value);
}
return value;
}
public Task SavePersonAsync(Person person)
{
return _repository.SavePersonAsync(person);
}
}
Our CachedRepository has a IRepository and basically wraps some behaviour. No need for inheritance but clearly we extended the behaviour. And by the way that goes very well with the first principle: Two concerns in two different objects. Win-Win! To sum it up: We want to decouple our objects. With that we can better test them, they are more maintainable (because they are smaller) and there is a higher chance we can re-use them. Liskov Substitution Principle A program that uses an interface must not be confused by an implementation of that interface. Source One thing before: It is often times stated that it is about base-classes and their relations to the derived children. But furthermore it is also about interfaces and their derived instances. To put this very simple: Let's imagine you have light-bulb and you want to screw this into a socket and you asked your friend to hand you a light bulb and he gives you a candle you would look a bit confused. That said if you model relationships your "is-a" should behave like its parent. Another example: public class Car
{
public virtual void RefuelGasoline(int amountInLiters) {}
}
public class ElectronicVehicle : Car
{
public override void RefuelGasoline(int amountInLiters) => throw new InvalidOperationException("No need for gasoline");
}
This violates the principle.Why? Because the "base" class aka car suggests that we can fuel a car. If I use now the EV I get an exception instead of a successful operation. Another example would be a Bird class which has a Fly method. Now we have a derived class named Pidgeon and that is totally fine but we can also add a public class Penguin : Bird ... well Penguins can't fly and therefore violate the principle. The fix could be like this: public class Bird {}
public class FlyingBird : Bird
{
public virtual void Fly() {}
}
public class Chicken : Bird {}
public class Pigeon : FlyingBird {}
The Liskov Substitution Principle gives us an indication whether or not our modelled relationship is good. We also could slice our hierarchy differently. Or we could use composition here instead of the approach via inheritance. Again the target is to decouple our code to increase maintainability. Interface Segregation Principle Keep interfaces small so that users don’t end up depending on things they don’t need. Source The idea behind this principle is to have the "smallest" amount of function in a given interface. So instead of having an interface with 20 functions, you might want to have 5 interfaces with 4 methods. That goes hand in hand with the first principle: Single responsibility principle. On a practical side you increase decoupling and reusability. Just imagine you have an interface with 20 methods, having a second implementation is almost impossible or you have a lot of empty methods. Plus how should someone use that anywhere (I will not start talking about unit testing)? Let's have a counter example: public interface IKitchenUtility
{
public void CoolFood();
public void FreezeFood();
public void StartBoilingWater();
public void StartFan();
}
We have two possibles paths to go from here: - Having everything clumped together in one big messy class
- Having classes which only implement a fraction of the code, like:
public class Fridge : IKitchenUtility
{
public void CoolFood() => { ... }
public void FreezeFood() => { ... }
public void StartBoilingWarter() => throw new NotSupportedException("I cool stuff, don't heat it up");
public void StartFan() => throw new NotSupportedException("I am not a oven");
}
So the better and more appropiate way to go is to have smaller interfaces: public interface IFridge
{
public void CoolFood();
public void FreezeFood();
}
public interface IKettle
{
public void StartBoilingWater();
}
public interface IOven
{
public void StartFan();
}
And the respective kitchen utilities implement their interface: public class Fridge : IFridge { ... }
public class Kettle : IKettle { ... }
Still you have the option to build stuff like that if you really want: public class HeatingFridge : IFridge, IOven { ... }
So we can easily build up our application from smaller components. Dependency Inversion Principle. Depend in the direction of abstraction. High level modules should not depend upon low level details. Source Let's dissect the principle by seeing a counter example. We have a service which transfers money from one customer to another. public class CashAccountRepository
{
public int GetCashAmount(User user) {}
}
public class CashValidator
{
public CashValidator(CashAccountRepository cashAccountRepository)
public bool CanUserSendMoney(User user, int money) => cashAccountRepository.GetCashAmount(user) >= money;
}
public class MoneyTransferService
{
public class MoneyTransferService(CashValidator validator) { ... }
public class SendMoney(User a, User b) => { ... }
}
That works perfectly, so what is the problem? Well for starters all the services are coupled together. Now the logic checks against the cash-account but what happens if we want to check against a different savings-account? Well we have to refactor our whole logic because of that. And that is solely because we are relying on the concrete implementation and not on the abstraction. We coupled our code and made testing way more difficult then it has to be. So the idea of "inversion" is, that we take this responsibility away from the object to the caller. So we are just relying on the interface for example. That would make it super easy to exchange the cash account with the saving account and don't have to change any line of code in our MoneyTransferService . And boing: Open-Close Principle fulfilled! I hope you see a common theme here that all of the principles play hand in hand. public class CashAccountRepository : IBankAccountRepository
{
public int GetCashAmount(User user) {}
}
public class SavingsAccountRepository : IBankAccountRepository
{
public int GetCashAmount(User user) {}
}
public class CashValidator : IValidator
{
public CashValidator(IBankAccountRepository bankAccountRepository)
public bool CanUserSendMoney(User user, int money) => bankAccountRepository.GetCashAmount(user) >= money;
}
public class SavingAccountValidator : IValidator
{
public CashValidator(IBankAccountRepository bankAccountRepository)
public bool CanUserSendMoney(User user, int money) => bankAccountRepository.GetCurrentCashAccount(user) >= money;
}
public class MoneyTransferService
{
public class MoneyTransferService(IValidator validator) { ... }
public class SendMoney(User a, User b) => { ... }
}
var validator = new CashValidator(new CashAccountRepository());
var cashAccountTransferService = new MoneyTransferService(validator);
And that is where DI aka Dependency Injection starts. It does that last part for you. That is the whole magic. Well a bit more but the fundamental concept is that. You tell which services is used in which context. ConclusionI hope I could give you a good overview over the SOLID principles and why they are there in the first place. They help you to write cleaner code and be more flexible in the future.
|