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:
- Easier Testing - Each layer can be tested independently
- Maintainability - Changes in one layer don't affect others
- Flexibility - Can replace infrastructure components without changing business logic
- Scalability - Can optimize read and write operations separately with CQRS
- 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.