Lesson 4
Applying SOLID Principles in C#
Introduction

Welcome to the final lesson of the "Applying Clean Code Principles" course! Throughout this course, we've covered vital principles such as DRY (Don't Repeat Yourself), KISS (Keep It Simple, Stupid), and the Law of Demeter, all of which are foundational to writing clean and efficient code. In this culminating lesson, we'll explore the SOLID Principles, a set of design principles introduced by Robert C. Martin, commonly known as "Uncle Bob." Understanding SOLID is crucial for creating software that is flexible, scalable, and easy to maintain. Let's dive in and explore these principles together.

SOLID Principles at a Glance

To start off, here's a quick overview of the SOLID Principles and their purposes:

  • Single Responsibility Principle (SRP): Each class or module should only have one reason to change, meaning it should have only one job or responsibility.
  • Open/Closed Principle (OCP): Software entities should be open for extension but closed for modification.
  • Liskov Substitution Principle (LSP): Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
  • Interface Segregation Principle (ISP): No client should be forced to depend on methods it does not use.
  • Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions.

These principles are guidelines that help programmers write code that is easier to understand and more flexible to change, leading to cleaner and more maintainable codebases. Let's explore each principle in detail.

Single Responsibility Principle

The Single Responsibility Principle states that each class should have only one reason to change, meaning it should only have one job or responsibility. This helps in reducing the complexity and enhancing the readability and maintainability of the code. Consider the following:

C#
1public class User 2{ 3 public void PrintUserInfo() 4 { 5 // Print user information 6 } 7 8 public void StoreUserData() 9 { 10 // Store user data in the database 11 } 12}

In the above code, the User class has two responsibilities: printing user information and storing user data. This violates the Single Responsibility Principle by taking on more than one responsibility. Let's refactor:

C#
1public class User 2{ 3 // User-related attributes and methods 4} 5 6public class UserPrinter 7{ 8 public void PrintUserInfo(User user) 9 { 10 // Print user information 11 } 12} 13 14public class UserDataStore 15{ 16 public void StoreUserData(User user) 17 { 18 // Store user data in the database 19 } 20}

In the refactored code, we have three classes, each handling a specific responsibility. This makes the code cleaner and easier to manage.

Open/Closed Principle

The Open/Closed Principle advises that software entities should be open for extension but closed for modification. This allows for enhancing and extending functionalities without altering existing code, reducing errors and ensuring stable systems. Consider this example:

C#
1public class Rectangle 2{ 3 public double Width { get; set; } 4 public double Height { get; set; } 5} 6 7public class AreaCalculator 8{ 9 public double CalculateRectangleArea(Rectangle rectangle) 10 { 11 return rectangle.Width * rectangle.Height; 12 } 13}

In this setup, if we want to add a new shape like Circle, we need to modify the AreaCalculator class, violating the Open/Closed Principle. Here is an improved version using polymorphism:

C#
1public interface Shape 2{ 3 double CalculateArea(); 4} 5 6public class Rectangle : Shape 7{ 8 public double Width { get; } 9 public double Height { get; } 10 11 public Rectangle(double width, double height) 12 { 13 Width = width; 14 Height = height; 15 } 16 17 public double CalculateArea() 18 { 19 return Width * Height; 20 } 21} 22 23public class Circle : Shape 24{ 25 public double Radius { get; } 26 27 public Circle(double radius) 28 { 29 Radius = radius; 30 } 31 32 public double CalculateArea() 33 { 34 return Math.PI * Radius * Radius; 35 } 36} 37 38public class AreaCalculator 39{ 40 public double CalculateArea(Shape shape) 41 { 42 return shape.CalculateArea(); 43 } 44}

Now, new shapes can be added without altering AreaCalculator. This setup adheres to the Open/Closed Principle by leaving the original code unchanged when extending functionalities.

Liskov Substitution Principle

The Liskov Substitution Principle ensures that objects of a subclass should be able to replace objects of a superclass without altering the functionality or causing any errors in the program.

C#
1public class Bird 2{ 3 public virtual void Fly() 4 { 5 Console.WriteLine("Flying"); 6 } 7} 8 9public class Ostrich : Bird 10{ 11 public override void Fly() 12 { 13 throw new NotSupportedException("Ostrich can't fly"); 14 } 15}

Here, substituting an instance of Bird with Ostrich causes an issue because Ostrich cannot fly, leading to an exception. Let's refactor:

C#
1public class Bird 2{ 3 // Common behaviors for all birds 4} 5 6public class FlyingBird : Bird 7{ 8 public virtual void Fly() 9 { 10 Console.WriteLine("Flying"); 11 } 12} 13 14public class Ostrich : Bird 15{ 16 // Specific behaviors for ostriches 17}

By introducing FlyingBird and having only birds that can actually fly inherit from it, we can substitute Bird with Ostrich without errors, adhering to Liskov’s Substitution Principle.

Interface Segregation Principle

The Interface Segregation Principle states that no client should be forced to depend on methods it does not use. Interfaces should be split into smaller, more specific entities so that clients only implement the methods they need:

C#
1public interface Worker 2{ 3 void Work(); 4 void Eat(); 5} 6 7public class Robot : Worker 8{ 9 public void Work() 10 { 11 // Robot work functions 12 } 13 14 public void Eat() 15 { 16 // Robots don't eat, but must implement this method 17 } 18}

Robot being forced to implement Eat() violates the Interface Segregation Principle. Here's the refactored version:

C#
1public interface Workable 2{ 3 void Work(); 4} 5 6public interface IEatable 7{ 8 void Eat(); 9} 10 11public class Robot : Workable 12{ 13 public void Work() 14 { 15 // Robot work functions 16 } 17}

Now, Robot only implements the Workable interface, adhering to the Interface Segregation Principle.

Dependency Inversion Principle

The Dependency Inversion Principle dictates that high-level modules should not depend on low-level modules, but both should depend on abstractions. Here's an example:

C#
1public class LightBulb 2{ 3 public void TurnOn() 4 { 5 Console.WriteLine("LightBulb turned on"); 6 } 7 8 public void TurnOff() 9 { 10 Console.WriteLine("LightBulb turned off"); 11 } 12} 13 14public class Switch 15{ 16 private LightBulb _lightBulb; 17 18 public Switch() 19 { 20 _lightBulb = new LightBulb(); 21 } 22 23 public void Operate() 24 { 25 // Operate on the light bulb 26 } 27}

Here, Switch directly depends on LightBulb, making it difficult to extend the system with new devices without modifying Switch. Every time a new device type is introduced, the Switch class would need modification, leading to tight coupling.

To adhere to the Dependency Inversion Principle, we introduce an abstraction:

C#
1// Abstraction: Interface representing switchable devices 2public interface ISwitchable 3{ 4 void TurnOn(); 5 void TurnOff(); 6} 7 8// Low-level module: Implementation of the ISwitchable interface 9public class LightBulb : ISwitchable 10{ 11 public void TurnOn() 12 { 13 Console.WriteLine("LightBulb turned on"); 14 } 15 16 public void TurnOff() 17 { 18 Console.WriteLine("LightBulb turned off"); 19 } 20} 21 22// High-level module: Uses abstraction (ISwitchable) to operate on switchable devices 23public class Switch 24{ 25 private ISwitchable _client; 26 27 public Switch(ISwitchable client) 28 { 29 _client = client; 30 } 31 32 public void Operate() 33 { 34 // Operate on the switchable client 35 } 36}

Now Switch uses the ISwitchable interface, which can be implemented by any switchable device. This setup allows the Switch class to remain unchanged when introducing new devices, thus following the Dependency Inversion Principle by depending on an abstraction and reducing the system's rigidity.

Review and Next Steps

In this lesson, we delved into the SOLID Principles — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. These principles guide developers to create code that is maintainable, scalable, and easy to extend or modify. As you prepare for the upcoming practice exercises, remember that applying these principles in real-world scenarios will significantly enhance your coding skills and codebase quality. Good luck, and happy coding! 🎓

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.