Become a C# Pro and Write Clean Code Like a Boss with These SOLID Principles Best Practices
Bilal Arshad
Posted on December 21, 2022
Clean code is code that is easy to read, understand, and maintain. It is a critical aspect of software development, as it can have a major impact on the quality, reliability, and performance of a project. In this article, we will explore some key principles of clean code and how they can be applied to C# development.
Code Smells and the Clean Code Principles
One key concept in clean code is the idea of "code smells." A code smell is a symptom of a deeper problem in the code, such as poor design, complexity, or lack of maintainability. Identifying and addressing code smells is an important step in writing clean code.
One way to identify code smells is to use the principles outlined in Robert C. Martin's book "Clean Code." These principles include:
- Meaningful names: Use clear, descriptive names for variables, functions, and other elements of your code. Avoid using abbreviations or acronyms unless they are widely known and used in your field.
- Simplicity: Avoid unnecessary complexity and strive for simplicity in your code. Use the simplest solution that meets your needs and avoid adding unnecessary features or functionality.
- Comments: Use comments sparingly and only to provide additional context or clarify your intentions. Avoid using comments to describe what the code is doing, as this should be evident from the code itself.
- Formatting: Use indentation, whitespace, and other formatting techniques to make your code easy to read and navigate. Group related code together and use clear, consistent formatting throughout the project.
The SOLID Principles
Another key concept in clean code is the SOLID principles, a set of guidelines for object-oriented design developed by Robert C. Martin for designing object-oriented software that aims to make code more flexible, maintainable, and scalable. The SOLID principles consist of the following:
Single Responsibility Principle (SRP)
Each class should have a single, well-defined responsibility. This helps prevent unnecessary complexity and makes the code easier to understand and maintain.
// Bad
public class Customer
{
public string Name { get; set; }
public double TotalPurchases { get; set; }
public bool IsEligibleForDiscount()
{
return TotalPurchases >= 100;
}
public double CalculateDiscount()
{
if (TotalPurchases >= 100)
{
return TotalPurchases * 0.1;
}
else
{
return 0;
}
}
public void ApplyDiscount(double discount)
{
TotalPurchases -= discount;
}
}
// Good
public class Customer
{
public string Name { get; set; }
public double TotalPurchases { get; set; }
public bool IsEligibleForDiscount()
{
return TotalPurchases >= 100;
}
}
public class DiscountCalculator
{
public static double CalculateDiscount(double totalPurchases)
{
if (totalPurchases >= 100)
{
return totalPurchases * 0.1;
}
else
{
return 0;
}
}
}
public class Order
{
public Customer Customer { get; set; }
public double Total { get; set; }
public void ApplyDiscount()
{
if (Customer.IsEligibleForDiscount())
{
double discount = DiscountCalculator.CalculateDiscount(Total);
Total -= discount;
}
}
}
In this example, the Customer class violates the Single Responsibility Principle because it has multiple responsibilities. It stores customer information and also contains logic for calculating and applying discounts.
By separating these responsibilities into separate classes, we can better adhere to the Single Responsibility Principle. The Customer class now only has a single responsibility: storing customer information. The DiscountCalculator class has the responsibility of calculating discounts, and the Order class has the responsibility of applying discounts to orders. This makes the code easier to understand and maintain.
Open/Closed Principle (OCP)
Classes should be open for extension, but closed for modification. This means that you should be able to add new functionality to a class without changing its existing code.
// Bad
public class Rectangle
{
public double Width { get; set; }
public double Height { get; set; }
public double CalculateArea()
{
return Width * Height;
}
}
public class Circle
{
public double Radius { get; set; }
public double CalculateArea()
{
return Math.PI * Radius * Radius;
}
}
public class AreaCalculator
{
public double CalculateArea(object shape)
{
if (shape is Rectangle)
{
return ((Rectangle)shape).CalculateArea();
}
else if (shape is Circle)
{
return ((Circle)shape).CalculateArea();
}
else
{
throw new ArgumentException("Invalid shape");
}
}
}
// Good
public interface IShape
{
double CalculateArea();
}
public class Rectangle : IShape
{
public double Width { get; set; }
public double Height { get; set; }
public double CalculateArea()
{
return Width * Height;
}
}
public class Circle : IShape
{
public double Radius { get; set; }
public double CalculateArea()
{
return Math.PI * Radius * Radius;
}
}
public class AreaCalculator
{
public double CalculateArea(IShape shape)
{
return shape.CalculateArea();
}
}
In this example, the AreaCalculator class is now "closed" for modification, as it does not need to be modified when new shapes are added. Instead, we can simply create new classes that implement the IShape interface and pass them to the CalculateArea method. This allows us to extend the functionality of the AreaCalculator class without modifying its code.
Liskov Substitution Principle (LSP)
Subtypes should be substitutable for their base types. This means that if you have a base class and a subclass, the subclass should be able to be used in the same way as the base class.
// Bad
public class Rectangle
{
public double Width { get; set; }
public double Height { get; set; }
public virtual double CalculateArea()
{
return Width * Height;
}
}
public class Square : Rectangle
{
public new double Width
{
get { return base.Width; }
set
{
base.Width = value;
base.Height = value;
}
}
public new double Height
{
get { return base.Height; }
set
{
base.Width = value;
base.Height = value;
}
}
}
public class AreaCalculator
{
public double CalculateArea(Rectangle shape)
{
return shape.CalculateArea();
}
}
// Good
public class Rectangle
{
public virtual double Width { get; set; }
public virtual double Height { get; set; }
public virtual double CalculateArea()
{
return Width * Height;
}
}
public class Square : Rectangle
{
public override double Width
{
get { return base.Width; }
set
{
base.Width = value;
base.Height = value;
}
}
public override double Height
{
get { return base.Height; }
set
{
base.Width = value;
base.Height = value;
}
}
}
public class AreaCalculator
{
public double CalculateArea(Rectangle shape)
{
return shape.CalculateArea();
}
}
In this example, the Square class violates the Liskov Substitution Principle because it has different behavior than the base Rectangle class. Specifically, setting the Width or Height of a Square also sets the other dimension, which is not the case for a Rectangle. This can cause issues when using the Square class in code that expects a Rectangle.
By making the Width and Height properties virtual and overridden in the Square class, we ensure that the Square class behaves correctly as a substitute for the Rectangle class. This allows us to use the Square class in the AreaCalculator without any issues.
Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use. This means that you should create specific, focused interfaces rather than large, general ones.
// Bad
public interface IShape
{
double Width { get; set; }
double Height { get; set; }
double CalculateArea();
double CalculatePerimeter();
}
public class Rectangle : IShape
{
public double Width { get; set; }
public double Height { get; set; }
public double CalculateArea()
{
return Width * Height;
}
public double CalculatePerimeter()
{
return 2 * (Width + Height);
}
}
public class Circle : IShape
{
public double Width { get; set; }
public double Height { get; set; }
public double CalculateArea()
{
return Math.PI * Width * Height;
}
public double CalculatePerimeter()
{
return 2 * Math.PI * Math.Sqrt((Width * Width + Height * Height) / 2);
}
}
// Good
public interface IShape
{
double CalculateArea();
}
public interface IHasPerimeter
{
double CalculatePerimeter();
}
public class Rectangle : IShape, IHasPerimeter
{
public double Width { get; set; }
public double Height { get; set; }
public double CalculateArea()
{
return Width * Height;
}
public double CalculatePerimeter()
{
return 2 * (Width + Height);
}
}
public class Circle : IShape, IHasPerimeter
{
public double Width { get; set; }
public double Height { get; set; }
public double CalculateArea()
{
return Math.PI * Width * Height;
}
public double CalculatePerimeter()
{
return 2 * Math.PI * Math.Sqrt((Width * Width + Height * Height) / 2);
}
}
In this example, the IShape interface violates the Interface Segregation Principle because it has both area and perimeter calculation methods. This means that any class that implements IShape must also implement both of these methods, even if it only needs one of them.
By breaking the IShape interface into two separate interfaces, IShape and IHasPerimeter, we can ensure that classes only need to implement the methods that they actually need. This allows us to create more flexible and maintainable code.
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Instead, both should depend on abstractions. This helps promote flexibility and maintainability.
// Bad
public class Database
{
public void Save(object obj)
{
// Save object to database
}
}
public class CustomerService
{
private readonly Database _database;
public CustomerService(Database database)
{
_database = database;
}
public void SaveCustomer(Customer customer)
{
_database.Save(customer);
}
}
// Good
public interface IDatabase
{
void Save(object obj);
}
public class Database : IDatabase
{
public void Save(object obj)
{
// Save object to database
}
}
public class CustomerService
{
private readonly IDatabase _database;
public CustomerService(IDatabase database)
{
_database = database;
}
public void SaveCustomer(Customer customer)
{
_database.Save(customer);
}
}
In this example, the CustomerService class violates the Dependency Inversion Principle because it depends on a concrete implementation of the Database class. This makes it difficult to change the implementation of the database without modifying the CustomerService class.
By using an interface (IDatabase) and injecting it into the CustomerService class, we invert the dependency. Now, the CustomerService class depends on the abstraction (the interface) rather than the concrete implementation. This allows us to change the implementation of the database without modifying the CustomerService class, making the code more flexible and maintainable.
By following these principles, you can write clean code and design object-oriented software that is easier to understand, maintain, and extend.
I would love to hear from you! If you have any tips or experiences that you would like to share with our community, please leave a comment below. Your insights and stories can be incredibly valuable to others who may be struggling through their software engineering careers.
Thank you in advance for contributing to this discussion!
Posted on December 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
December 21, 2022