Design Principles and Patterns

DESIGN PRINCIPLES

SOLID

The SOLID principles are essential for writing clean, maintainable, and scalable code in C#. Each principle addresses a specific aspect of software design, helping developers avoid common pitfalls. Here’s a detailed explanation of each principle, accompanied by examples.

1. Single Responsibility Principle (SRP)

A class should have only one reason to change, meaning it should only have one job or responsibility. This principle helps reduce the complexity of the code and makes it easier to maintain.

Example:

public class User { public string Name { get; set; } public string Email { get; set; } } public class UserService { public void RegisterUser(User user) { // Logic to register user } } public class EmailService { public void SendWelcomeEmail(User user) { // Logic to send a welcome email } }
In this example, the UserService class is responsible only for user registration, while the EmailService handles sending emails. This separation of concerns adheres to SRP.

2. Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification. This means that the behavior of a module can be extended without altering its source code.

Example:

public abstract class Shape { public abstract double Area(); } public class Circle : Shape { public double Radius { get; set; } public override double Area() { return Math.PI * Radius * Radius; } } public class Rectangle : Shape { public double Width { get; set; } public double Height { get; set; } public override double Area() { return Width * Height; } }
Here, the Shape class can be extended to add new shapes like Triangle without modifying existing classes, adhering to OCP.

3. Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types without altering the correctness of the program. This principle ensures that a derived class can stand in for its base class.

Example:

public class Bird { public virtual void Fly() { // Flying logic } } public class Sparrow : Bird { public override void Fly() { // Sparrow flying logic } } public class Ostrich : Bird { public override void Fly() { throw new NotSupportedException("Ostriches cannot fly."); } }
In this scenario, substituting Ostrich for Bird violates LSP because it cannot fulfill the expected behavior of flying. A better design would separate flying birds from non-flying birds.

4. Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they do not use. This principle promotes creating smaller, more specific interfaces.

Example:

public interface IFlyable { void Fly(); } public interface ISwimmable { void Swim(); } public class Duck : IFlyable, ISwimmable { public void Fly() { // Duck flying logic } public void Swim() { // Duck swimming logic } } public class Fish : ISwimmable { public void Swim() { // Fish swimming logic } }
In this example, IFlyable and ISwimmable are separate interfaces, allowing classes to implement only what they need, adhering to ISP.

5. Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules but both should depend on abstractions. This principle encourages the use of interfaces or abstract classes to reduce coupling.

Example:

public interface IDatabase { void SaveData(string data); } public class SqlDatabase : IDatabase { public void SaveData(string data) { // Logic to save data to SQL database } } public class DataProcessor { private readonly IDatabase _database; public DataProcessor(IDatabase database) { _database = database; } public void ProcessData(string data) { // Process data... _database.SaveData(data); } }
In this case, DataProcessor depends on the IDatabase interface rather than a concrete implementation, allowing for greater flexibility and easier testing.

DRY (Don't Repeat Yourself)

The DRY principle emphasizes reducing duplication of code. Each piece of knowledge must have a single, unambiguous representation within a system. This principle helps prevent redundancy, making the codebase easier to maintain.

Example:

Instead of duplicating the logic for calculating tax in multiple places, you can create a single method:
public class TaxCalculator { public decimal CalculateTax(decimal amount) { return amount * 0.15m; // 15% tax } }
This method can be reused wherever tax calculations are needed.

KISS (Keep It Simple, Stupid)

The KISS principle advocates for simplicity in design. Systems should be as simple as possible, avoiding unnecessary complexity. Simple designs are easier to understand, maintain, and extend.

Example:

Instead of using complex algorithms for simple tasks, opt for straightforward solutions:
public int Add(int a, int b) { return a + b; // Simple addition }
This keeps the code easy to read and maintain.

YAGNI (You Aren't Gonna Need It)

The YAGNI principle states that you should not add functionality until it is necessary. This helps avoid over-engineering and keeps the codebase lean.

Example:

Avoid implementing features that are not currently required:
public class UserService { public void RegisterUser(User user) { // Registration logic // Avoid adding features like password recovery if it's not needed yet } }
Implement features only when they are needed in the project.

Separation of Concerns

This principle suggests that a software application should be divided into distinct sections, each addressing a separate concern. This leads to better organization and easier maintenance.

Example:

In an MVC application, separate business logic, data access, and presentation layers:
public class UserController : Controller { private readonly IUserService _userService; public UserController(IUserService userService) { _userService = userService; } public IActionResult Register(User user) { _userService.RegisterUser(user); return View(); } }
Here, the controller handles user requests, while the service manages business logic.

Law of Demeter (Principle of Least Knowledge)

This principle states that a module should not know about the inner details of the objects it interacts with. It promotes loose coupling between components.

Example:

Instead of chaining method calls, keep interactions simple:
public class Order { public Customer GetCustomer() { return new Customer(); } } public class Customer { public string GetName() { return "John Doe"; } } // Poor practice: string customerName = order.GetCustomer().GetName(); // Violates Law of Demeter // Better practice: Customer customer = order.GetCustomer(); string customerName = customer.GetName();
This reduces dependencies and improves code maintainability.

Dependency Inversion Principle (DI)

The principle that promotes highly cohesive and loosely coupled code is the Dependency Inversion Principle (DIP), which is one of the SOLID principles.

Explanation of Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules; both should depend on abstractions (e.g., interfaces). This principle encourages developers to design systems in such a way that components are loosely coupled, meaning they can be changed independently without affecting other parts of the system.

Benefits of DIP

  1. Loose Coupling: By depending on abstractions rather than concrete implementations, components can be easily swapped out without requiring changes to other parts of the system. This leads to a more flexible architecture.
  1. High Cohesion: Each module can focus on a specific responsibility without being burdened by dependencies on other modules. This results in code that is easier to understand and maintain.
  1. Testability: Code that adheres to DIP is easier to unit test because dependencies can be mocked or stubbed, allowing for isolated testing of components.

Example of DIP in C#

Here’s a simple illustration of the Dependency Inversion Principle:
// Abstraction public interface IMessageService { void SendMessage(string message); } // Low-level module public class EmailService : IMessageService { public void SendMessage(string message) { // Logic to send an email Console.WriteLine("Email sent: " + message); } } // High-level module public class NotificationManager { private readonly IMessageService _messageService; // Dependency is injected through the constructor public NotificationManager(IMessageService messageService) { _messageService = messageService; } public void Notify(string message) { _messageService.SendMessage(message); } } // Usage class Program { static void Main() { IMessageService emailService = new EmailService(); NotificationManager notificationManager = new NotificationManager(emailService); notificationManager.Notify("Hello, World!"); } }

DESIGN PATTERNS

notion image
Design patterns are proven solutions to common problems in software design, particularly in C#. They provide templates for building robust, maintainable, and scalable applications. Here’s an overview of the key design patterns categorized into three main groups: Creational, Structural, and Behavioral.

Types of Design Patterns

1. Creational Design Patterns

Creational patterns deal with object creation mechanisms, trying to create objects in a manner suitable to the situation. They help abstract the instantiation process.
  • Singleton: Ensures a class has only one instance and provides a global point of access to it.
  • Factory Method: Defines an interface for creating an object, but lets subclasses alter the type of objects that will be created.
  • Abstract Factory: Provides an interface for creating families of related or dependent objects without specifying their concrete classes.
  • Builder: Separates the construction of a complex object from its representation, allowing the same construction process to create different representations.
  • Prototype: Creates new objects by copying an existing object, known as the prototype.

Example of Singleton in C#

public class Singleton { private static Singleton _instance; private Singleton() { } public static Singleton Instance { get { if (_instance == null) { _instance = new Singleton(); } return _instance; } } }

2. Structural Design Patterns

Structural patterns focus on how classes and objects are composed to form larger structures. They help ensure that if one part of a system changes, the entire system doesn’t need to do the same.
  • Adapter: Allows incompatible interfaces to work together.
  • Bridge: Separates an object’s interface from its implementation.
  • Composite: Composes objects into tree structures to represent part-whole hierarchies.
  • Decorator: Adds responsibilities to objects dynamically.
  • Facade: Provides a simplified interface to a complex subsystem.
  • Flyweight: Reduces the cost of creating and manipulating a large number of similar objects.
  • Proxy: Provides a surrogate or placeholder for another object to control access to it.

Example of Adapter Pattern in C#

public interface ITarget { void Request(); } public class Adaptee { public void SpecificRequest() { // Specific request implementation } } public class Adapter : ITarget { private readonly Adaptee _adaptee; public Adapter(Adaptee adaptee) { _adaptee = adaptee; } public void Request() { _adaptee.SpecificRequest(); } }

3. Behavioral Design Patterns

Behavioral patterns are concerned with algorithms and the assignment of responsibilities between objects. They help define how objects interact in a system.
  • Chain of Responsibility: Passes a request along a chain of handlers.
  • Command: Encapsulates a command request as an object.
  • Interpreter: Implements a specialized language.
  • Iterator: Provides a way to access the elements of a collection without exposing its underlying representation.
  • Mediator: Defines an object that encapsulates how a set of objects interact.
  • Memento: Captures and restores an object's internal state.
  • Observer: Notifies a list of dependents when the state of an object changes.
  • State: Allows an object to alter its behavior when its internal state changes.
  • Strategy: Defines a family of algorithms, encapsulates each one, and makes them interchangeable.
  • Template Method: Defines the skeleton of an algorithm in a method, deferring some steps to subclasses.
  • Visitor: Represents an operation to be performed on the elements of an object structure.

Example of Observer Pattern in C#

public interface IObserver { void Update(string message); } public class ConcreteObserver : IObserver { public void Update(string message) { Console.WriteLine("Received update: " + message); } } public class Subject { private readonly List<IObserver> _observers = new List<IObserver>(); public void Attach(IObserver observer) { _observers.Add(observer); } public void Notify(string message) { foreach (var observer in _observers) { observer.Update(message); } } }