📐Design Patterns and Architecture

Use design patterns and architectural principles to write clean, scalable, and maintainable software.

💎 SOLID Principles

Welcome to our chapter on the SOLID Principles! These five fundamental design principles will help you create cleaner, more maintainable code in your C# projects. Let's dive into each principle and see how they can transform your approach to software architecture.

💡 Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have only one reason to change. In other words, each class should have a single responsibility or job.

  • Each class should focus on one task
  • Separate concerns into different classes
  • Makes code easier to understand and maintain

💡 Open/Closed Principle (OCP)

The Open/Closed Principle states that classes should be open for extension but closed for modification. This means you can add new functionality without changing the existing code.

  • Prefer composition over inheritance
  • Use interfaces and abstract classes
  • Makes code more flexible and resilient

💡 Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program.

  • Ensure proper inheritance hierarchies
  • Avoid breaking the contract between parent and child classes
  • Makes code more robust and reliable

💡 Interface Segregation Principle (ISP)

The Interface Segregation Principle states that no client should be forced to depend on methods it does not use. Instead of one large interface, create smaller, specialized interfaces.

  • Split interfaces into smaller, focused ones
  • Reduce unnecessary dependencies
  • Makes code more modular and easier to maintain

💡 Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions.

  • Depend on abstractions, not implementations
  • Use dependency injection to manage dependencies
  • Makes code more testable and maintainable

💡 Real-World Examples

Let's see how these principles can be applied in real-world scenarios. We'll use the example of a file processing system.

SRP Example

// Good implementation - Single responsibility
class FileProcessor {
    public void Process(string filePath) {
        var content = ReadFile(filePath);
        var result = AnalyzeContent(content);
        SaveResult(result);
    }
}

// Bad implementation - Multiple responsibilities
class FileProcessor {
    public void ProcessAndLog(string filePath) {
        var content = ReadFile(filePath);
        var result = AnalyzeContent(content);
        SaveResult(result);
        LogProcessing(filePath, result);
    }
}

OCP Example

// Good implementation - Open for extension
class FileProcessor {
    public void Process(string filePath) {
        var content = ReadFile(filePath);
        var result = AnalyzeContent(content);
        SaveResult(result);
    }
}

interface IAnalyzer {
    object Analyze(string content);
}

// Extend functionality by creating new implementations
class CSVAnalyzer : IAnalyzer {
    public object Analyze(string csv) {
        // Analysis logic for CSV files
    }
}

LSP Example

// Good implementation - Liskov substitution
class Shape {
    public virtual void Draw() { }
}

class Circle : Shape {
    public override void Draw() {
        // Draw circle logic
    }
}

ISP Example

// Good implementation - Interface segregation
interface IReadble {
    string Read();
}

interface IWrittable {
    void Write(string content);
}

// Bad implementation - Fat interface
interface IFileSystem {
    string Read();
    void Write(string content);
    void Delete();
}

DIP Example

// Good implementation - Dependency inversion
interface ILogger {
    void Log(string message);
}

class FileProcessor {
    private readonly ILogger _logger;

    public FileProcessor(ILogger logger) {
        _logger = logger;
    }

    public void Process(string filePath) {
        // Processing logic
        _logger.Log("File processed successfully");
    }
}

💡 Best Practices

  • Always follow the Single Responsibility Principle to keep your classes focused
  • Use interfaces and abstract classes to implement the Open/Closed Principle
  • Ensure proper inheritance hierarchies for the Liskov Substitution Principle
  • Split interfaces into smaller, more focused ones following the Interface Segregation Principle
  • Depend on abstractions rather than implementations to adhere to the Dependency Inversion Principle

Common Pitfalls to Avoid

  • Don't create classes with multiple responsibilities
  • Avoid tight coupling between high-level and low-level modules
  • Don't use inheritance when composition would be more appropriate
  • Avoid creating large, monolithic interfaces that force clients to depend on unnecessary methods

💡 Helpful Tips for Applying SOLID Principles

  • Start by identifying areas where you can apply the Single Responsibility Principle
  • Use dependency injection to implement the Dependency Inversion Principle effectively
  • Create small, focused interfaces following the Interface Segregation Principle
  • Test your code for proper inheritance and substitution as per the Liskov Substitution Principle

🎮 Common Design Patterns

Welcome to our chapter on Common Design Patterns in C#! In this section, we'll explore some of the most widely used patterns that help developers create maintainable, scalable, and efficient software. Whether you're building web applications, desktop apps, or enterprise systems, these patterns will be your go-to tools for solving common architectural challenges.

💡 Singleton Pattern

The Singleton Pattern ensures that a class has only one instance and provides a global point of access to it. This is useful for managing resources like database connections or logging systems.

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

Best Practices for Singleton

  • Use Singleton when there must be exactly one instance of a class.
  • Ensure proper thread safety in multithreaded environments.
  • Avoid using Singletons for dependencies that should be injected.

💡 Factory Pattern

The Factory Pattern provides an interface for creating objects without specifying their exact implementation. This promotes loose coupling and allows for easier maintenance of the codebase.

public abstract class LoggerFactory
{
    public abstract ILogger CreateLogger();
}

public class ConsoleLoggerFactory : LoggerFactory
{
    public override ILogger CreateLogger()
    {
        return new ConsoleLogger();
    }
}

💡 Strategy Pattern

The Strategy Pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. This allows for dynamic selection of the algorithm used at runtime.

public interface ISortingStrategy
{
    void Sort(int[] array);
}

public class BubbleSortStrategy : ISortingStrategy
{
    public void Sort(int[] array) { /* Implementation */ }
}

💡 Observer Pattern

The Observer Pattern establishes a one-to-many relationship between objects so that when one object changes state, all its dependents are notified and updated automatically. This is commonly used in event-driven systems.

public interface IObservable
{
    void Subscribe(IObserver observer);
    void Unsubscribe(IObserver observer);
    void Notify();
}

public class StockTicker : IObservable
{
    private List<IObserver> _observers = new List<IObserver>();
    
    public void UpdatePrice(decimal price) { /* Implementation */ }
}

💡 Repository Pattern

The Repository Pattern serves as a layer between the application and the database, abstracting data access operations. This promotes separation of concerns and makes the code more testable.

public interface IRepository<T>
{
    IEnumerable<T> GetAll();
    T GetById(int id);
    void Add(T entity);
    void Update(T entity);
    void Delete(T entity);
}

public class EfRepository<T> : IRepository<T> where T : class
{
    private readonly DbContext _context;

    public EfRepository(DbContext context)
    {
        _context = context;
    }
}

💡 Summary of Key Patterns

  • Singleton: One instance, global access.
  • Factory: Abstract object creation for flexibility.
  • Strategy: Encapsulate and switch algorithms.
  • Observer: Event-driven notifications.
  • Repository: Data access abstraction.

🧱 Clean Architecture and Dependency Injection

Clean Architecture is a software design approach that focuses on creating modular, testable, and maintainable codebases. It emphasizes separating concerns and decoupling modules to improve overall system flexibility.

💡 Core Principles of Clean Architecture

  • Single Responsibility Principle: Each module should have a single, well-defined responsibility.
  • Interface Segregation Principle: Modules should only depend on interfaces they actually use.
  • Dependency Inversion Principle: Dependencies should be abstract and inverted to prevent tight coupling.

💡 Clean Architecture Layers

  • Domain Layer: Core business logic and rules.
  • Application Layer: Orchestration of domain operations.
  • Infrastructure Layer: External dependencies like databases, APIs, etc.

💡 Dependency Injection Basics

Dependency Injection (DI) is a technique where components receive their dependencies from external sources rather than creating them internally. This improves testability and maintainability.

// Without DI
public class UserService {
    private readonly Database _database = new Database();

    public void SaveUser(User user) {
        _database.Save(user);
    }
}

// With DI
public class UserService {
    private readonly IDatabase _database;

    public UserService(IDatabase database) {
        _database = database;
    }

    public void SaveUser(User user) {
        _database.Save(user);
    }
}

💡 Implementing DI in .NET

The .NET Core DI Container provides built-in support for dependency injection through the IServiceProvider interface.

public class Program {
    public static void Main(string[] args) {
        var builder = WebApplication.CreateBuilder(args);

        // Register services
        builder.Services.AddScoped<IUserService, UserService>();
        builder.Services.AddSingleton<IDatabase, Database>();

        var app = builder.Build();

        app.Run();
    }
}

💡 Best Practices for Clean Architecture and DI

  • Use abstract interfaces for all dependencies.
  • Avoid direct service creation using new().
  • Keep domain layer independent of infrastructure concerns.
  • Implement proper error handling across layers.

Common Pitfalls to Avoid

  • Don't tightly couple modules together.
  • Avoid creating monolithic layers that mix concerns.
  • Don't use concrete implementations directly in higher layers.

Quiz

Question 1 of 15

Which principle states that a class should have only one reason to change?

  • Open/Closed Principle
  • Single Responsibility Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle