Why Is SOLID Gold Standard In .NET?
Andrew
Posted on February 22, 2022
These five letters become like a threshold that divides young developers from mature ones. I have visited a lot of tech interviews from both sides, and as a reviewer, I can say that question about SOLID could be a killing bullet for developers.
I want to show that it is pretty easy to understand solid. Let`s recall what does it state for, relying on the wiki.
SOLID is an acronym for five design principles intended to make software designs more understandable, flexible, and maintainable in software engineering.
- The Single-responsibility principle
- The Open–closed principle
- The Liskov substitution principle
- The Interface segregation principle
- The Dependency inversion principle
We are going to check each of the five principles one by one with examples in c#.
Single Responsibility Principle (SRP)
This one is used by many developers unintendedly because simple logic suggests how to improve our code. Single responsibility tells us to write small classes which do only a single part of logic.
Let`s take a look at some service implementation:
public class Entity
{
public int Id { get; set; }
public string Name { get; set; }
}
public class CreateEntityRequest
{
public string Name { get; set; }
}
public class EntityResponse
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Repository
{
public void Save(Entity entity)
{
}
}
public class Service
{
private readonly Repository _repository;
public EntityResponse CreateEntity(CreateEntityRequest request)
{
if (string.IsNullOrEmpty(request.Name))
{
throw new Exception("Validation Exception Name is empty");
}
var entity = new Entity
{
Name = request.Name,
};
_repository.Save(entity);
return new EntityResponse
{
Id = entity.Id,
Name = entity.Name,
};
}
}
I want to highlight that this service violates SRP. Its responsibilities are validation, mapping, and actual business logic. This example illustrates that the service knows a lot about validation rules and how to map create request to entity and entity to the response.
Another thing that becomes much harder when you decide to abandon the SR principle is testing. Testing the above example will cause a lot of code to cover all possible cases, and eventually, it becomes unsupported and takes a lot of time.
public class CreateEntityValidator
{
public void Validate(CreateEntityRequest request)
{
if (string.IsNullOrEmpty(request.Name))
{
throw new Exception("Validation Exception Name is empty");
}
}
}
public class Mapper
{
public Entity Map(CreateEntityRequest request)
{
return new Entity
{
Name = request.Name,
};
}
public EntityResponse Map(Entity entity)
{
return new EntityResponse
{
Id = entity.Id,
Name = entity.Name,
};
}
}
public class Service
{
private readonly Repository _repository;
private readonly CreateEntityValidator _createEntityValidator;
private readonly Mapper _mapper;
public EntityResponse CreateEntity(CreateEntityRequest request)
{
_createEntityValidator.Validate(request);
var entity = _mapper.Map(request);
_repository.Save(entity);
return _mapper.Map(entity);
}
}
Now, the code looks better, we moved validation and mapping to separate classes, and the service doesn`t know the internal rules of how these processes work. Now our service does not violate SRP, and in terms of testing, it is pretty easy to test 3 small classes which do a small part of logic.
Open-Close Principle (OCP)
This principle sounds quite tricky and could blow the mind
software entities (classes, modules, functions, etc.) should be open for extension but closed for modification
We`ll look at the situation when we work with a few data sources, and the later amount of external services may be increased.
// provided by external lib
public class S3Client
{
public IEnumerable<string> GetBucketBlobs()
{
return new List<string>
{
"s3Blob"
};
}
}
// provided by external lib
public class AzureStorageClient
{
public IEnumerable<string> GetStorageBlobs()
{
return new List<string>
{
"AzureBlob"
};
}
}
internal class Service
{
public IEnumerable<string> GetAllBlobs(List<object> clients)
{
var blobs = new List<string>();
foreach(var client in clients)
{
if(client is AzureStorageClient)
{
blobs.AddRange((client as AzureStorageClient).GetStorageBlobs());
}
if (client is S3Client)
{
blobs.AddRange((client as S3Client).GetBucketBlobs());
}
}
return blobs;
}
}
Here is the classic example, you have to work with different clients that provide different contracts but logically, they do the same. The best way would be to use the Adapter pattern.
Take a look at Design Patterns — Adapter .NET
The adapter allows to make the same contract for different implementations, and it perfectly suits this example:
public class S3Client
{
public IEnumerable<string> GetBucketBlobs()
{
return new List<string>
{
"s3Blob"
};
}
}
public class AzureStorageClient
{
public IEnumerable<string> GetStorageBlobs()
{
return new List<string>
{
"AzureBlob"
};
}
}
public interface IExternalSource
{
IEnumerable<string> GetBlobs();
}
public class S3Source : IExternalSource
{
private readonly S3Client _s3Client;
public S3Source(S3Client s3Client)
{
this._s3Client = s3Client;
}
public IEnumerable<string> GetBlobs()
{
return _s3Client.GetBucketBlobs();
}
}
public class AzureSource : IExternalSource
{
private readonly AzureStorageClient _azureClient;
public AzureSource(AzureStorageClient azureClient)
{
this._azureClient = azureClient;
}
public IEnumerable<string> GetBlobs()
{
return _azureClient.GetStorageBlobs();
}
}
public class FtpSource : IExternalSource
{
private readonly FtpClient _ftpClient;
public FtpSource(FtpClient ftpClient)
{
this._ftpClient = ftpClient;
}
public IEnumerable<string> GetBlobs()
{
return _ftpClient.GetBlobs();
}
}
internal class Service
{
public IEnumerable<string> GetAllBlobs(List<IExternalSource> clients)
{
var blobs = new List<string>();
foreach (var client in clients)
{
blobs.AddRange(client.GetBlobs());
}
return blobs;
}
}
As you can see IExternalSource interface was introduced, and proper wrappers were created for different sources. Service implementation was changed to accept a list of IExternalSource.
Let’s understand what does it means to open for extension.
In the example above, we can extend our service capabilities to work with additional sources, as, for example, an FTP source was added. This is the idea of an extension that we can add new functionality to our business logic without touching existing parts. In the real world, our service would be covered with unit tests, and new functionality doesn`t make us rewrite that tests. We just introduced a new source and new unit tests for that separate class.
At the end of this part, we must summarize what close for modification means. The example above demonstrates that to support a new source, we don`t need to touch Service implementation at all. In other words, the service is closed to any code modification.
Liskov-Substitution Principle (LSP)
This principle is closely related to the previous one:
Object and a sub-object (such as a class that extends the first class) must be interchangeable without breaking the program
The Definition states that we should write programs in such a way that it`s possible to use any descendant instead of a base class or interface. It is also very closely related to the interface segregation principle, as we can see a bit later:
cs
internal class Service
{
public IEnumerable<string> GetAllBlobs(List<IExternalSource> clients)
{
var blobs = new List<string>();
foreach (var client in clients)
{
blobs.AddRange(client.GetBlobs());
}
return blobs;
}
}
Let`s take a look at the example from OCP. It works and adheres to LSP. Inside method GetAllBlobs, we do not care what kind of instance we actually get. We know a contract of interface and assume that all descendants who implement interface properly implement the GetBlobs method.
This principle forces us not to change child behavior drastically.
Interface Segregation Principle (ISP)
This one is also quite related to principles that we have already learned.
no code should be forced to depend on methods it does not use
In simple words, it means that we should never leave a method empty or throw NotImplementedException
public class Item
{
}
public interface IService
{
void Create(Item item);
Item GetItem();
}
internal class Service : IService
{
public void Create(Item item)
{
Console.WriteLine("Item Created");
}
public Item GetItem()
{
return new Item();
}
}
internal class ServiceHistoryDecorator : IService
{
private readonly IService _service;
public ServiceHistoryDecorator(IService service)
{
_service = service;
}
public void Create(Item item)
{
this._service.Create(item);
Console.WriteLine("Log info that item was created");
}
public Item GetItem()
{
throw new NotImplementedException("Logger is only for modification operations");
}
}
In the example above, you see that ServiceHistoryDecorator throws NotImplementedException because it allows to log only modification operations, but due to inheritance from common interface IService, it`s required to implement a contract with all methods.
`cs
public class Item
{
}
public interface IModificationService
{
void Create(Item item);
}
public interface IService : IModificationService
{
Item GetItem();
}
internal class Service : IService
{
public void Create(Item item)
{
Console.WriteLine("Item Created");
}
public Item GetItem()
{
return new Item();
}
}
internal class ServiceHistoryDecorator : IModificationService
{
private readonly IService _service;
public ServiceHistoryDecorator(IService service)
{
_service = service;
}
public void Create(Item item)
{
this._service.Create(item);
Console.WriteLine("Log info that item was created");
}
}
`
After small changes code looks better. We extracted modification operation into a separate interface and updated inheritance rules. Now we don`t need to implement read operations in ServiceHistoryDecorator.
Dependency Inversion Principle (DIP)
Don`t miss it with Dependency injection, which is also pronounced a bit similar:
High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
This principle only sounds unclear; indeed, it`s quite simple, and most .NET developers follow it implicitly.
Terrible example when High-Level component Service directly depends on Low-Level component Repository:
public class Entity
{
}
public class Repository
{
public void Save(Entity entity)
{
//... writes it to database
}
}
internal class Service
{
public void Save(Entity entity)
{
var repo = new Repository();
repo.Save(entity);
}
}
To follow the dependency inversion principle, we need to introduce an abstraction for our repository IRepository and make the service depend on abstraction instead of the concrete repository:
public class Entity
{
}
public interface IRepository
{
void Save(Entity entity);
}
public class Repository : IRepository
{
public void Save(Entity entity)
{
//... writes it to database
}
}
internal class Service
{
private readonly IRepository _repository;
public Service(IRepository repository)
{
_repository = repository;
}
public void Save(Entity entity)
{
_repository.Save(entity);
}
}
Conclusion
For .NET developers, it`s pretty easy to follow SOLID principles. Language helps us write code with best practices. As you notice, some principles are highly coupled, and if you violate one of the solid principles, there is a huge chance that another principle is also violated.
Good luck with writing a good code.
Any questions or comments? Ping me on LinkedIn or comment below. And if you liked this post, please give it a clap and share it with all of your friends.
Twitter: https://twitter.com/KyliaBoy
Linkedin: https://www.linkedin.com/in/andrew-kulta/
More articles you can find at:
https://blog.akyltech.com/
Posted on February 22, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.