Introduction:

Clean Architecture and SOLID principles help us build better software that's easier to change, test, and maintain. This guide explains these concepts using simple language and real C# examples.

SOLID Principles in Real Life:

SOLID is a set of five principles that help make your code more flexible and less likely to break when changes happen.

Single Responsibility Principle (SRP):

Each class should do just one job. This makes code easier to understand and change.

Bad Example:

public class OrderProcessor
{
    public void ProcessOrder(Order order)
    {
        // Validate order
        if (order.Items.Count == 0)
            throw new Exception("Order has no items");

        // Calculate total
        decimal total = 0;
        foreach (var item in order.Items)
            total += item.Price * item.Quantity;

        // Save to database
        using (var connection = new SqlConnection("connection-string"))
        {
            // Database code here
        }

        // Send confirmation email
        SmtpClient client = new SmtpClient();
        client.Send("store@example.com", order.CustomerEmail, "Order Confirmation", "Your order is confirmed");
    }
}

Better Example:

public class OrderValidator
{
    public bool Validate(Order order) => order.Items.Count > 0;
}

public class OrderCalculator
{
    public decimal CalculateTotal(Order order)
    {
        decimal total = 0;
        foreach (var item in order.Items)
            total += item.Price * item.Quantity;
        return total;
    }
}

public class OrderRepository
{
    public void Save(Order order)
    {
        // Database code here
    }
}

public class EmailService
{
    public void SendOrderConfirmation(Order order)
    {
        // Email sending code here
    }
}

public class OrderProcessor
{
    private readonly OrderValidator _validator;
    private readonly OrderCalculator _calculator;
    private readonly OrderRepository _repository;
    private readonly EmailService _emailService;

    // Constructor with dependencies

    public void ProcessOrder(Order order)
    {
        if (!_validator.Validate(order))
            throw new Exception("Invalid order");

        order.Total = _calculator.CalculateTotal(order);
        _repository.Save(order);
        _emailService.SendOrderConfirmation(order);
    }
}

Open/Closed Principle (OCP):

Classes should be open for extension but closed for modification. Add new features by creating new code, not changing existing code.

// Before OCP
public class DiscountCalculator
{
    public decimal ApplyDiscount(Order order, string customerType)
    {
        if (customerType == "Regular")
            return order.Total * 0.05m;
        else if (customerType == "Premium")
            return order.Total * 0.10m;
        return 0;
    }
}

// After OCP
public interface IDiscountStrategy
{
    decimal ApplyDiscount(Order order);
}

public class RegularCustomerDiscount : IDiscountStrategy
{
    public decimal ApplyDiscount(Order order) => order.Total * 0.05m;
}

public class PremiumCustomerDiscount : IDiscountStrategy
{
    public decimal ApplyDiscount(Order order) => order.Total * 0.10m;
}

// Now when we need to add a new discount type, we just create a new class
public class GoldCustomerDiscount : IDiscountStrategy
{
    public decimal ApplyDiscount(Order order) => order.Total * 0.15m;
}

Liskov Substitution Principle (LSP):

If a function uses a base class, it should be able to use any of its derived classes without knowing it.

// Violating LSP
public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

    public int Area() => Width * Height;
}

public class Square : Rectangle
{
    public override int Width
    {
        get => base.Width;
        set { base.Width = value; base.Height = value; }
    }

    public override int Height
    {
        get => base.Height;
        set { base.Width = value; base.Height = value; }
    }
}

// Code that breaks when using a Square:
Rectangle r = new Square();
r.Width = 5;
r.Height = 10;
// Area should be 50, but for Square it's 100!

// Following LSP - use a different approach
public interface IShape
{
    int Area();
}

public class Rectangle : IShape
{
    public int Width { get; set; }
    public int Height { get; set; }
    public int Area() => Width * Height;
}

public class Square : IShape
{
    public int SideLength { get; set; }
    public int Area() => SideLength * SideLength;
}

Interface Segregation Principle (ISP):

Create small, specific interfaces rather than large, general-purpose ones. Classes shouldn't be forced to implement methods they don't use.

// Bad approach - one large interface
public interface IReportGenerator
{
    void GeneratePdf();
    void GenerateExcel();
    void GenerateJson();
    void SendEmail();
    void PrintReport();
}

// Better approach - segregated interfaces
public interface IPdfReportGenerator
{
    void GeneratePdf();
}

public interface IExcelReportGenerator
{
    void GenerateExcel();
}

public interface IJsonReportGenerator
{
    void GenerateJson();
}

public interface IEmailSender
{
    void SendEmail();
}

public interface IPrinter
{
    void PrintReport();
}

// Now classes can implement only what they need
public class SalesReport : IPdfReportGenerator, IEmailSender
{
    public void GeneratePdf() { /* implementation */ }
    public void SendEmail() { /* implementation */ }
}

Dependency Inversion Principle (DIP):

High-level modules should not depend on low-level modules. Both should depend on abstractions.

// Bad approach - direct dependency
public class NotificationService
{
    public void SendNotification(string message)
    {
        var emailSender = new EmailSender();
        emailSender.SendEmail("user@example.com", message);
    }
}

// Better approach - dependency inversion
public interface IMessageSender
{
    void SendMessage(string to, string message);
}

public class EmailSender : IMessageSender
{
    public void SendMessage(string to, string message)
    {
        // Send email implementation
    }
}

public class SmsSender : IMessageSender
{
    public void SendMessage(string to, string message)
    {
        // Send SMS implementation
    }
}

public class NotificationService
{
    private readonly IMessageSender _messageSender;

    public NotificationService(IMessageSender messageSender)
    {
        _messageSender = messageSender;
    }

    public void SendNotification(string to, string message)
    {
        _messageSender.SendMessage(to, message);
    }
}

Clean Architecture Layers:

Clean Architecture divides your application into layers with clear responsibilities. Each inner layer doesn't know about outer layers.

Domain Layer:

This is the core of your application containing business entities, rules, and logic.

public class Order
{
    public int Id { get; private set; }
    public List<OrderItem> Items { get; private set; } = new List<OrderItem>();
    public decimal Total { get; private set; }
    public OrderStatus Status { get; private set; }

    public void AddItem(Product product, int quantity)
    {
        var item = new OrderItem(product, quantity);
        Items.Add(item);
        CalculateTotal();
    }

    private void CalculateTotal()
    {
        Total = Items.Sum(item => item.Subtotal);
    }

    public void Submit()
    {
        // Business rule: Can't submit empty orders
        if (!Items.Any())
            throw new DomainException("Cannot submit empty order");

        Status = OrderStatus.Submitted;
    }
}

public class OrderItem
{
    public Product Product { get; }
    public int Quantity { get; }
    public decimal Subtotal => Product.Price * Quantity;

    public OrderItem(Product product, int quantity)
    {
        if (quantity <= 0)
            throw new DomainException("Quantity must be positive");

        Product = product;
        Quantity = quantity;
    }
}

public enum OrderStatus
{
    Draft,
    Submitted,
    Processed,
    Shipped,
    Delivered,
    Cancelled
}

Application Layer:

Contains application-specific business rules. It coordinates the flow between the domain and infrastructure.

public interface IOrderRepository
{
    Task<Order> GetByIdAsync(int id);
    Task SaveAsync(Order order);
}

public class SubmitOrderUseCase
{
    private readonly IOrderRepository _orderRepository;
    private readonly IEmailService _emailService;

    public SubmitOrderUseCase(
        IOrderRepository orderRepository,
        IEmailService emailService)
    {
        _orderRepository = orderRepository;
        _emailService = emailService;
    }

    public async Task SubmitOrderAsync(int orderId)
    {
        // Get the order from repository
        var order = await _orderRepository.GetByIdAsync(orderId);
        if (order == null)
            throw new NotFoundException($"Order {orderId} not found");

        // Apply domain logic
        order.Submit();

        // Save changes
        await _orderRepository.SaveAsync(order);

        // Send notification
        await _emailService.SendOrderConfirmationAsync(order);
    }
}

Infrastructure Layer:

Implements interfaces defined in the application layer. Handles database access, external APIs, file systems, etc.

public class SqlOrderRepository : IOrderRepository
{
    private readonly DbContext _dbContext;

    public SqlOrderRepository(DbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Order> GetByIdAsync(int id)
    {
        return await _dbContext.Orders
            .Include(o => o.Items)
            .ThenInclude(i => i.Product)
            .FirstOrDefaultAsync(o => o.Id == id);
    }

    public async Task SaveAsync(Order order)
    {
        _dbContext.Update(order);
        await _dbContext.SaveChangesAsync();
    }
}

public class SmtpEmailService : IEmailService
{
    public async Task SendOrderConfirmationAsync(Order order)
    {
        // Implementation using SMTP client
    }
}

Presentation Layer:

This could be a Web API, MVC application, or any other UI. It depends on the Application layer.

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly SubmitOrderUseCase _submitOrderUseCase;

    public OrdersController(SubmitOrderUseCase submitOrderUseCase)
    {
        _submitOrderUseCase = submitOrderUseCase;
    }

    [HttpPost("{id}/submit")]
    public async Task<IActionResult> SubmitOrder(int id)
    {
        try
        {
            await _submitOrderUseCase.SubmitOrderAsync(id);
            return Ok();
        }
        catch (NotFoundException ex)
        {
            return NotFound(ex.Message);
        }
        catch (DomainException ex)
        {
            return BadRequest(ex.Message);
        }
    }
}

CQRS and MediatR:

CQRS (Command Query Responsibility Segregation) separates read operations (queries) from write operations (commands). MediatR helps implement this pattern by decoupling handlers from senders.

Commands (Write Operations):

public class CreateOrderCommand : IRequest<int>
{
    public string CustomerEmail { get; set; }
    public List<OrderItemDto> Items { get; set; }
}

public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
    private readonly IOrderRepository _orderRepository;

    public CreateOrderCommandHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<int> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
    {
        var order = new Order { CustomerEmail = request.CustomerEmail };

        foreach (var item in request.Items)
        {
            var product = await _productRepository.GetByIdAsync(item.ProductId);
            order.AddItem(product, item.Quantity);
        }

        await _orderRepository.SaveAsync(order);
        return order.Id;
    }
}

Queries (Read Operations):

public class GetOrderByIdQuery : IRequest<OrderDto>
{
    public int OrderId { get; set; }
}

public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto>
{
    private readonly IOrderRepository _orderRepository;

    public GetOrderByIdQueryHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<OrderDto> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
    {
        var order = await _orderRepository.GetByIdAsync(request.OrderId);

        if (order == null)
            return null;

        // Map domain entity to DTO
        return new OrderDto
        {
            Id = order.Id,
            CustomerEmail = order.CustomerEmail,
            Total = order.Total,
            Status = order.Status.ToString(),
            Items = order.Items.Select(i => new OrderItemDto
            {
                ProductId = i.Product.Id,
                ProductName = i.Product.Name,
                Quantity = i.Quantity,
                UnitPrice = i.Product.Price,
                Subtotal = i.Subtotal
            }).ToList()
        };
    }
}

Controller Using MediatR:

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<OrderDto>> GetOrder(int id)
    {
        var query = new GetOrderByIdQuery { OrderId = id };
        var result = await _mediator.Send(query);

        if (result == null)
            return NotFound();

        return Ok(result);
    }

    [HttpPost]
    public async Task<ActionResult<int>> CreateOrder(CreateOrderCommand command)
    {
        var orderId = await _mediator.Send(command);
        return CreatedAtAction(nameof(GetOrder), new { id = orderId }, orderId);
    }
}

Real-World Example: E-commerce Order System

Let's see how all these concepts come together in a real-world e-commerce application:

Domain Layer Example:

public class Product
{
    public int Id { get; private set; }
    public string Name { get; private set; }
    public decimal Price { get; private set; }
    public int StockQuantity { get; private set; }

    public bool IsInStock(int quantity) => StockQuantity >= quantity;

    public void ReduceStock(int quantity)
    {
        if (!IsInStock(quantity))
            throw new DomainException("Not enough stock");

        StockQuantity -= quantity;
    }
}

public class Order
{
    // Domain entities and business rules as shown earlier
}

Application Layer Example:

// Using CQRS with MediatR

// Command
public class PlaceOrderCommand : IRequest<int>
{
    public string CustomerEmail { get; set; }
    public List<OrderItemRequest> Items { get; set; }
}

public class OrderItemRequest
{
    public int ProductId { get; set; }
    public int Quantity { get; set; }
}

// Command Handler
public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, int>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;
    private readonly IUnitOfWork _unitOfWork;

    public PlaceOrderCommandHandler(
        IOrderRepository orderRepository,
        IProductRepository productRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<int> Handle(PlaceOrderCommand request, CancellationToken cancellationToken)
    {
        var order = new Order { CustomerEmail = request.CustomerEmail };

        foreach (var item in request.Items)
        {
            var product = await _productRepository.GetByIdAsync(item.ProductId);

            if (product == null)
                throw new NotFoundException($"Product {item.ProductId} not found");

            if (!product.IsInStock(item.Quantity))
                throw new DomainException($"Not enough stock for product {product.Name}");

            product.ReduceStock(item.Quantity);
            order.AddItem(product, item.Quantity);
        }

        order.Submit();

        _orderRepository.Add(order);
        await _unitOfWork.SaveChangesAsync();

        return order.Id;
    }
}

Infrastructure Layer Example:

public class AppDbContext : DbContext
{
    public DbSet<Order> Orders { get; set; }
    public DbSet<OrderItem> OrderItems { get; set; }
    public DbSet<Product> Products { get; set; }

    // Configuration here
}

public class EfOrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public EfOrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public void Add(Order order)
    {
        _context.Orders.Add(order);
    }

    public async Task<Order> GetByIdAsync(int id)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .ThenInclude(i => i.Product)
            .FirstOrDefaultAsync(o => o.Id == id);
    }
}

public class EfUnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;

    public EfUnitOfWork(AppDbContext context)
    {
        _context = context;
    }

    public async Task SaveChangesAsync()
    {
        await _context.SaveChangesAsync();
    }
}

API Layer Example:

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    public async Task<ActionResult<int>> PlaceOrder(PlaceOrderCommand command)
    {
        try
        {
            var orderId = await _mediator.Send(command);
            return Ok(orderId);
        }
        catch (NotFoundException ex)
        {
            return NotFound(ex.Message);
        }
        catch (DomainException ex)
        {
            return BadRequest(ex.Message);
        }
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<OrderDto>> GetOrder(int id)
    {
        var query = new GetOrderByIdQuery { OrderId = id };
        var order = await _mediator.Send(query);

        if (order == null)
            return NotFound();

        return Ok(order);
    }
}

Benefits of This Approach:

  1. Easier Testing - Each layer can be tested independently
  2. Maintainability - Changes in one layer don't affect others
  3. Flexibility - Can replace infrastructure components without changing business logic
  4. Scalability - Can optimize read and write operations separately with CQRS
  5. Team Collaboration - Different teams can work on different layers

Summary:

By following Clean Architecture and SOLID principles, you create a system that's organized, flexible, and easy to maintain. Using CQRS with MediatR helps separate read and write operations, making your code even cleaner. The real benefit shows when requirements change - you'll be able to adapt your application without rewriting everything.