Skip to main content

Lab 2 - SOLID principles

The most common practices used in modern software development are the SOLID principles. Although they are very general and primarily apply to object-oriented programming languages, they are used today for most enterprise applications.

S. Single-responsibility principle

A class should have only one reason to be modified.

Implementing multiple functionalities in a class is harmful for code management because the more a class does, the more it will be referenced in the code, and any change to it can affect many places, which can sometimes lead to errors and overloading when adapting the existing code. Therefore, in modern applications, software components are divided into categories to follow the single-responsibility principle (e.g., database entities, data transfer objects, value objects, etc.).

O. Open–closed principle

Software entities should be open for extension but closed for modification.

When a software component is implemented and used, the developer makes certain assumptions about its usage and tests it accordingly; any subsequent modification of that component may lead to errors or undefined behavior. Thus, software components, once implemented, should not be modified but should extend their functionality through other means. The most common examples of extending functionality are inheritance, extension methods, or aspect-oriented programming, where classes, methods, or fields are annotated and gain additional functionalities.

L. Liskov substitution principle

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

This principle is especially useful for unit and integration testing; in well-written applications, when components reference other components, they hold a reference to an interface or another type of abstraction, thus hiding the implementation. In testing scenarios, this is useful because production implementations can be replaced with mock implementations to isolate test cases, such as for API consumers.

I. Interface segregation principle

Clients should not be forced to depend on interfaces they do not use.

Generally, when developing a software component with an interface, it is designed to solve a specific need; adding additional functionalities implicitly leads to harder-to-manage and less-readable code. Segregating interfaces with their cohesive functionalities (e.g., user management) makes the code modular, an advantage for development teams working on large projects, especially when using Agile methodologies.

D. Dependency inversion principle

Depend on abstractions, not concretions.

Instead of each software component depending directly on another, each implements an interface that is referenced instead. This decoupling of components makes applications more modular and easier to change. For example, if a software component is moved to an external service from its original location, we can simply retain the interface without changing the implementation that references the interface, and implement a client for the external service.

Exercises

To illustrate these principles, we will use the following code to improve it. The code below represents a lamp that has a functionality to turn on and one to observe if it is on or not.

This example aims to help you understand how we can easily extend the code if we follow the given principles.

Lamp.cs
public class Lamp
{
public bool IsOn { get; private set; }
public string State => IsOn ? "On" : "Off";

public void Toggle() => IsOn = !IsOn;

public void Observe() => Console.WriteLine("The lamp is {0}!", State);
}
Program.cs
internal static class Program
{
public static async Task Main()
{
var lamp = new Lamp();

var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;

var read = Task.Run(() =>
{
while (!cancellationToken.IsCancellationRequested)
{
var line = Console.ReadLine();

switch (line)
{
case "toggle":
lamp.Toggle();
break;
case "exit":
cancellationTokenSource.Cancel();
break;
}
}

}, CancellationToken.None);

var observer = Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
lamp.Observe();

try
{
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
}
catch (TaskCanceledException)
{
Console.WriteLine("Shutting down...");
}
}
}, CancellationToken.None);

await read;
await observer;
}
}
  1. We want to segregate the two functionalities from the Lamp class and have a Lamp class that we only observe and a Button class that changes its state.
  2. We want to abstract the two implementations and let each class implement the interfaces corresponding to each functionality.
  3. In the Button class, we need to add new functionalities, add methods to turn the lamp on and off if it is not already in those states, and use them in the given command-line commands. Use extension methods for this.
  4. Install the Microsoft.Extensions.DependencyInjection package and use the ServiceCollection and ServiceProvider classes to build the necessary objects through dependency injection. Once a ServiceCollection is created, use the AddScoped<Interface, Implementation>() and BuildServiceProvider() functions to create a ServiceProvider. With ServiceProvider, the necessary objects can be instantiated using the GetRequiredService<Interface>() method.
  5. Add two other implementations to the code, FancyLamp, which changes the text displayed when observed, and FancyButton, which has three states: one off and two on with different colors for the lamp. Modify the code so that it is chosen from the command line which type of object should be instantiated.