Search icon CANCEL
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Conferences
Free Learning
Arrow right icon
Arrow up icon
GO TO TOP
The C# Workshop

You're reading from   The C# Workshop Kickstart your career as a software developer with C#

Arrow left icon
Product type Paperback
Published in Sep 2022
Publisher Packt
ISBN-13 9781800566491
Length 780 pages
Edition 1st Edition
Languages
Tools
Arrow right icon
Authors (3):
Arrow left icon
Almantas Karpavicius Almantas Karpavicius
Author Profile Icon Almantas Karpavicius
Almantas Karpavicius
Jason Hales Jason Hales
Author Profile Icon Jason Hales
Jason Hales
Mateus Viegas Mateus Viegas
Author Profile Icon Mateus Viegas
Mateus Viegas
Arrow right icon
View More author details
Toc

SOLID Principles in OOP

SOLID principles are a set of best practices for OOP. SOLID is an acronym for five principles, namely, single responsibility, open-closed, Liskov substitution, interface segregation, and dependency inversion. You will not explore each of these in detail.

Single Responsibility Principle

Functions, classes, projects, and entire systems change over time. Every change is potentially a breaking one, so you should limit the risk of too many things changing at a time. In other words, a part of a code block should have only a single reason to change.

For a function, this means that it should do just one thing and have no side effects. In practice, this means that a function should either change, or get something, but never do both. This also means that functions responsible for high-level things should not be mixed with functions that perform low-level things. Low-level is all about implementing interactions with hardware, and working with primitives. High-level is focused on compositions of software building blocks or services. When talking about high- and low-level functions, it is usually referred to as a chain of dependencies. If function A calls function B, A is considered higher-level than B. A function should not implement multiple things; it should instead call other functions that implement doing one thing. The general guideline for this is that if you think you can split your code into different functions, then in most cases, you should do that.

For classes, it means that you should keep them small and isolated from one another. An example of an efficient class is the File class, which can read and write. If it implemented both reading and writing, it would change for two reasons (reading and writing):

public class File
{
    public string Read(string filePath)
    {
        // implementation how to read file contents
        // complex logic
        return "";
    }
 
    public void Write(string filePath, string content)
    {
        // implementation how to append content to an existing file
        // complex logic
    }
}

Therefore, to conform to this principle, you can split the reading code into a class called Reader and writing code into a class called Writer, as follows:

public class Reader
{
    public string Read(string filePath)
    {
        // implementation how to read file contents
        // complex logic
        return "";
    }
}
public class Writer
{
    public void Write(string filePath, string content)
    {
        // implementation how to append content to an existing file
        // complex logic
    }
}

Now, instead of implementing reading and writing by itself, the File class will simply be composed of a reader and writer:

public class File
{
    private readonly Reader _reader;
    private readonly Writer _writer;
 
    public File()
    {
        _reader = new Reader();
        _writer = new Writer();
    }  
 
    public string Read(string filePath) => _reader.Read(filePath);
    public void Write(string filePath, string content) => _writer.Write(filePath, content);
}

Note

You can find the code used for this example at https://packt.link/PBppV.

It might be confusing because what the class does essentially remains the same. However, now, it just consumes a component and is not responsible for implementing it. A high-level class (File) simply adds context to how lower-level classes (Reader, Writer) will be consumed.

For a module (library), it means that you should strive to not introduce dependencies, which would be more than what the consumer would want. For example, if you are using a library for logging, it should not come with some third-party logging provider-specific implementation.

For a subsystem, it means that different systems should be as isolated as possible. If two (lower level) systems need to communicate, they could call one another directly. A consideration (not mandatory) would be to have a third system (higher-level) for coordination. Systems should also be separated through a boundary (such as a contract specifying communication parameters), which hides all the details. If a subsystem is a big library collection, it should have an interface to expose what it can do. If a subsystem is a web service, it should be a collection of endpoints. In any case, a contract of a subsystem should provide only the methods that the client may want.

Sometimes, the principle is overdone and classes are split so much that making a change requires changing multiple places. It does keep true to the principle, as a class will have a single reason to change, but in such a case, multiple classes will change for the same reason. For example, suppose you have two classes: Merchandise and TaxCalculator. The Merchandise class has fields for Name, Price, and Vat:

public class Merchandise
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    // VAT on top in %
    public decimal Vat { get; set; }
}

Next, you will create the TaxCalculator class. vat is measured as a percentage, so the actual price to pay will be vat added to the original price:

public static class TaxCalculator
{
    public static decimal CalculateNextPrice(decimal price, decimal vat)
    {
        return price * (1 + vat / 100);
    }
}

What would change if the functionality of calculating the price moved to the Merchandise class? You would still be able to perform the required operation. There are two key points here:

  • The operation by itself is simple.
  • Also, everything that the tax calculator needs come from the Merchandise class.

If a class can implement the logic by itself, as long as it is self-contained (does not involve extra components), it usually should. Therefore, a proper version of the code would be as follows:

public class Merchandise
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    // VAT on top in %
    public decimal Vat { get; set; }
    public decimal NetPrice => Price * (1 + Vat / 100);
}

This code moves the NetPrice calculation to the Merchandise class and the TaxCalculator class has been removed.

Note

Singe Responsibility Principle (SRP) can be summarized in a couple of words: split it. You can find the code used for this example at https://packt.link/lWxNO.

Open-Closed Principle

As mentioned previously, every change in code is potentially a breaking one. As a way around this, instead of changing existing code, it is often preferable to write new code. Every software entity should have an extension point, through which the changes should be introduced. However, after this change is done, a software entity should not be interfered with. The Open-Closed Principle (OCP) is hard to implement and takes a lot of practice, but the benefits (a minimum number of breaking changes) are well worth it.

If a multiple-step algorithm does not change, but its individual steps can change, you should split it into several functions. A change for an individual step will no longer affect the entire algorithm, but rather just that step. Such minimization of reasons for a single class or a function to change is what OCP is all about.

Note

You can find more information on OCP at https://social.technet.microsoft.com/wiki/contents/articles/18062.open-closed-principle-ocp.aspx.

Another example where you may want to implement this principle is a function working with combinations of specific values in code. This is called hardcoding and is generally deemed an inefficient practice. To make it work with new values, you might be tempted to create a new function, but by simply removing a hardcoded part and exposing it through function parameters, you can make it extensible. However, when you have variables that are known to be fixed and not changing, it is fine to hardcode them, but they should be flagged as constant.

Previously, you created a file class with two dependencies—Reader and Writer. Those dependencies are hardcoded, and leave you with no extension points. Fixing that will involve two things. First, add the virtual modifier for both the Reader and Writer class methods:

public virtual string Read(string filePath)
public virtual void Write(string filePath, string content)

Then, change the constructor of the File class so that it accepts instances of Reader and Writer, instead of hardcoding the dependencies:

public File(Reader reader, Writer writer)
{
    _reader = reader;
    _writer = writer;
}

This code enables you to override the existing reader and writer behavior and replace it with whatever behavior you want, that is, the File class extension point.

OCP can be summarized in a few words as don't change it, extend it.

Liskov Substitution

The Liskov Substitution Principle (LSP) is one of the most straightforward principles out there. It simply means that a child class should support all the public behavior of a parent class. If you have two classes, Car and CarWreck, where one inherits the other, then you have violated the principle:

class Car
{
    public object Body { get; set; }
    public virtual void Move()
    {
        // Moving
    }
}
class CarWreck : Car
{
    public override void Move()
    {
        throw new NotSupportedException("A broken car cannot start.");
    }
}

Note

You can find the code used for this example at https://packt.link/6nD76.

Both Car and CarWreck have a Body object. Car can move, but what about CarWreck? It can only stay in one place. The Move method is virtual because CarWreck intends to override it to mark it as not supported. If a child can no longer support what a parent can do, then it should no longer inherit that parent. In this case, a car wreck is not a car, it's simply a wreck.

How do you conform to this principle? All you have to do is to remove the inheritance relationship and replicate the necessary behavior and structure. In this case, CarWreck still has a Body object, but the Move method is unnecessary:

class CarWreck
{
    public object Body { get; set; }
}

Code changes happen quite often, and you can sometimes inadvertently use the wrong method to achieve your goals. Sometimes, you couple code in such a way that what you thought was flexible code turns out to be a complex mess. Do not use inheritance as a way of doing code reuse. Keep things small and compose them (again) instead of trying to override the existing behavior. Before things can be reusable, they should be usable. Design for simplicity and you will get flexibility for free.

LSP can be summarized in a few words: don't fake it.

Note

You can find more information on LSP at https://www.microsoftpressstore.com/articles/article.aspx?p=2255313.

Interface Segregation

The interface segregation principle is a special case of the OCP but is only applicable to contracts that will be exposed publicly. Remember, every change you make is potentially a breaking change, and this especially matters in making changes to a contract. Breaking changes are inefficient because they will often require effort to adapt to the change from multiple people.

For example, say you have an interface, IMovableDamageable:

interface IMovableDamageable
{
    void Move(Location location);
    float Hp{get;set;}
}

A single interface should represent a single concept. However, in this case, it does two things: move and manage Hp (hit points). By itself, an interface with two methods is not problematic. However, in scenarios of the implementation needing only a part of an interface, you are forced to create a workaround.

For example, score text is indestructible, but you would like it to be animated and to move it across a scene:

class ScoreText : IMovableDamageable
{
    public float Hp 
    { 
        get => throw new NotSupportedException(); 
        set => throw new NotSupportedException(); 
    }
 
    public void Move(Location location)
    {
        Console.WriteLine($"Moving to {location}");
    }
}
 
public class Location
{
}

Note

The point here isn't to print the location; just to give an example of where it is used. It's up to location's implementation whether it will be printed or not as such.

Taking another example, you might have a house that does not move but can be destroyed:

class House : IMovableDamageable
{
    public float Hp { get; set; }
 
    public void Move(Location location)
    {
        throw new NotSupportedException();
    }
}

In both scenarios, you worked around the issue by throwing NotSupportedException. However, another programmer should not be given an option to call code that never works in the first place. In order to fix the problem of representing too many concepts, you should split the IMoveableDamageable interface into IMoveable and IDamageable:

interface IMoveable
{
    void Move(Location location);
}
interface IDamageable
{
    float Hp{get;set;}
}

And the implementations can now get rid of the unnecessary parts:

class House : IDamageable
{
    public float Hp { get; set; }
}
 
class ScoreText : IMovable
{
    public void Move(Location location)
    {
        Console.WriteLine($"Moving to {location}");
    }
}

The Console.WriteLine, in the preceding code, would display the namespace name with the class name.

Note

Interface segregation can be summarized as don't enforce it. You can find the code used for this example at https://packt.link/32mwP.

Dependency Inversion

Large software systems can consist of millions of classes. Each class is a small dependency, and if unmanaged, the complexity might stack into something impossible to maintain. If one low-level component breaks, it causes a ripple effect, breaking the whole chain of dependencies. The dependency inversion principle states that you should avoid hard dependence on underlying classes.

Dependency injection is the industry-standard way of implementing dependency inversion. Do not mix the two; one is a principle and the other refers to the implementation of this principle.

Note that you can also implement dependency inversion without dependency injection. For example, when declaring a field, instead of writing something like private readonly List<int> _numbers = new List<int>();, it is preferable to write private readonly IList<int> = _numbers, which shifts dependency to abstraction (IList) and not implementation (List).

What is dependency injection? It is the act of passing an implementation and setting it to an abstraction slot. There are three ways to implement this:

  • Constructor injection is achieved by exposing an abstraction through the constructor argument and passing an implementation when creating an object and then assigning it to a field. Use it when you want to consistently use the same dependency in the same object (but not necessarily the same class).
  • Method injection is done by exposing an abstraction through a method argument, and then passing an implementation when calling that method. Use it when, for a single method, a dependency might vary, and you do not plan to store the dependency throughout that object's lifetime.
  • Property injection is implemented by exposing an abstraction through a public property, and then assigning (or not) that property to some exact implementation. Property injection is a rare way of injecting dependencies because it suggests that dependency might even be null or temporary and there are many ways in which it could break.

Given two types, interface IBartender { } and class Bar : Bartender { }, you can illustrate the three ways of dependency injection for a class called Bar.

First, prepare the Bar class for constructor injection:

class Bar
{
    private readonly IBartender _bartender;
 
    public Bar(IBartender bartender)
    {
        _bartender = bartender;
    }
}

The constructor injection is done as follows:

var bar = new Bar(new Bartender());

This kind of dependency injection is a dominating kind of inheritance, as it enforces stability through immutability. For example, some bars have just one bartender.

Method injection would look like this:

class Bar
{
    public void ServeDrinks(IBartender bartender)
    {
        // serve drinks using bartender
    }
}

The injection itself is as follows:

var bar = new Bar();
bar.ServeDrinks(new Bartender());

Often, this kind of dependency injection is called interface injection because the method often goes under an interface. The interface itself is a great idea, but that does not change the idea behind this kind of dependency injection. Use method injection when you immediately consume a dependency that you set, or when you have a complex way of setting new dependencies dynamically. For example, it makes sense to use different bartenders for serving drinks.

Finally, property injection can be done like this:

class Bar
{
    public IBartender Bartender { get; set; }
}

Bartender is now injected like this:

var bar = new Bar();
bar.Bartender = new Bartender();

For example, a bar might have bartenders changing shifts, but one bartender at a time.

Note

You can find the code used for this example at https://packt.link/JcmAT.

Property injection in other languages might have a different name: setter injection. In practice, components do not change that often, so this kind of dependency injection is the rarest.

For the File class, this should mean that instead of exposing classes (implementation), you should expose abstractions (interfaces). This means that your Reader and Writer classes should implement some contract:

public class Reader : IReader
public class Writer: IWriter

Your file class should expose reader and writer abstractions, instead of implementations, as follows:

private readonly IReader _reader;
private readonly IWriter _writer;
 
public File(IReader reader, IWriter writer)
{
    _reader = reader;
    _writer = writer;
}

This allows for a choice of the kind of IReader and IWriter you would like to inject. A different reader may read a different file format, or a different writer may output in a different way. You have a choice.

Dependency injection is a powerful tool that is used often, especially in an enterprise setting. It allows you to simplify complex systems by putting an interface in between and having 1:1 dependencies of implementation-abstraction-implementation.

Writing effective code that does not break can be paradoxical. It is the same as buying a tool from a shop; you can't know for sure how long it will last, or how well it will work. Code, just like those tools, might work now but break in the near future, and you will only know that it does not work if and when it breaks.

Observing and waiting, seeing how the code evolves, is the only way to know for sure if you have written an effective code. In small, personal projects, you might not even notice any changes, unless you expose the project to the public or involve other people. To most people, SOLID principles often sound like old, outdated principles, like over-engineering. But they are actually a set of best practices that have withstood the test of time, formulated by top professionals seasoned in enterprise settings. It is impossible to write perfect, SOLID code right away. In fact, in some cases, it is not even necessary (if a project is small and meant to be short-lived, for example). As someone who wants to produce quality software and work as a professional, you should practice it as early on as possible.

You have been reading a chapter from
The C# Workshop
Published in: Sep 2022
Publisher: Packt
ISBN-13: 9781800566491
Register for a free Packt account to unlock a world of extra content!
A free Packt account unlocks extra newsletters, articles, discounted offers, and much more. Start advancing your knowledge today.
Unlock this book and the full library FREE for 7 days
Get unlimited access to 7000+ expert-authored eBooks and videos courses covering every tech area you can think of
Renews at €18.99/month. Cancel anytime