Clean Architecture

Master Clean Architecture principles including layered architecture, dependency rules, and domain-driven design for building maintainable enterprise applications

Architecture
Clean Architecture
DDD
Design Patterns
Best Practices
SOLID
30 min read

Overview

Clean Architecture, popularized by Robert C. Martin (Uncle Bob), provides a blueprint for building maintainable systems. It's essential knowledge for mid-level to architect roles, often coming up in system design interviews and architectural discussions.

Core Concepts

The Dependency Rule

The fundamental rule: source code dependencies must point only inward, toward higher-level policies.

Outer Layers → Inner Layers āœ“
Inner Layers → Outer Layers āœ—

This means:

  • Domain knows nothing about Application, Infrastructure, or Presentation
  • Application knows about Domain but not Infrastructure or Presentation
  • Infrastructure and Presentation know about Application and Domain

Layer Breakdown

1. Domain Layer (Core)

The heart of the application containing business rules and logic.

// Entities - rich domain models
public class Product
{
    public int Id { get; private set; }
    public string Name { get; private set; }
    public decimal Price { get; private set; }
    public bool IsActive { get; private set; }

    public void Activate()
    {
        if (Price <= 0)
            throw new InvalidOperationException("Cannot activate product without price");

        IsActive = true;
    }

    public void UpdatePrice(decimal newPrice)
    {
        if (newPrice < 0)
            throw new ArgumentException("Price cannot be negative");

        Price = newPrice;
    }
}

// Value Objects - immutable, compared by value
public record Address(
    string Street,
    string City,
    string State,
    string ZipCode)
{
    public string FullAddress => $"{Street}, {City}, {State} {ZipCode}";
}

// Domain Services - complex business logic
public class PricingService
{
    public decimal CalculateDiscount(Customer customer, Order order)
    {
        if (customer.IsVip && order.Total > 1000)
            return order.Total * 0.15m;
        if (customer.IsVip)
            return order.Total * 0.10m;
        if (order.Total > 1000)
            return order.Total * 0.05m;

        return 0;
    }
}

// Domain Events
public record OrderSubmittedEvent(int OrderId, DateTime SubmittedAt);

2. Application Layer

Orchestrates domain objects to implement use cases.

// Use Case / Command Handler
public class PlaceOrderCommand
{
    public int CustomerId { get; set; }
    public List<OrderItemDto> Items { get; set; }
}

public interface IPlaceOrderHandler
{
    Task<Result<int>> HandleAsync(PlaceOrderCommand command);
}

public class PlaceOrderHandler : IPlaceOrderHandler
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly PricingService _pricingService;
    private readonly IEventBus _eventBus;

    public PlaceOrderHandler(
        IOrderRepository orderRepository,
        IProductRepository productRepository,
        ICustomerRepository customerRepository,
        PricingService pricingService,
        IEventBus eventBus)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
        _customerRepository = customerRepository;
        _pricingService = pricingService;
        _eventBus = eventBus;
    }

    public async Task<Result<int>> HandleAsync(PlaceOrderCommand command)
    {
        // 1. Validate
        var customer = await _customerRepository.GetByIdAsync(command.CustomerId);
        if (customer == null)
            return Result<int>.Failure("Customer not found");

        var productIds = command.Items.Select(i => i.ProductId).ToList();
        var products = await _productRepository.GetByIdsAsync(productIds);
        if (products.Count != productIds.Count)
            return Result<int>.Failure("Some products not found");

        // 2. Execute business logic
        var order = Order.Create(customer.Id);
        foreach (var item in command.Items)
        {
            var product = products.First(p => p.Id == item.ProductId);
            order.AddItem(product, item.Quantity);
        }

        var discount = _pricingService.CalculateDiscount(customer, order);
        order.ApplyDiscount(discount);

        order.Submit();

        // 3. Persist
        await _orderRepository.AddAsync(order);

        // 4. Publish events
        await _eventBus.PublishAsync(new OrderSubmittedEvent(order.Id, DateTime.UtcNow));

        return Result<int>.Success(order.Id);
    }
}

// DTOs for data transfer
public class OrderDto
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public decimal Total { get; set; }
    public string Status { get; set; }
    public List<OrderItemDto> Items { get; set; }
}

public class OrderItemDto
{
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

// Repository interfaces (defined in Application, implemented in Infrastructure)
public interface IOrderRepository
{
    Task AddAsync(Order order);
    Task<Order> GetByIdAsync(int id);
    Task UpdateAsync(Order order);
}

3. Infrastructure Layer

Implements technical concerns and external dependencies.

// Repository Implementation
public class OrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;

    public OrderRepository(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task AddAsync(Order order)
    {
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
    }

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

    public async Task UpdateAsync(Order order)
    {
        _context.Orders.Update(order);
        await _context.SaveChangesAsync();
    }
}

// External Service Integration
public class EmailService : IEmailService
{
    private readonly SmtpClient _smtpClient;

    public async Task SendAsync(string to, string subject, string body)
    {
        var message = new MailMessage("noreply@company.com", to, subject, body);
        await _smtpClient.SendMailAsync(message);
    }
}

// Event Bus Implementation
public class RabbitMqEventBus : IEventBus
{
    private readonly IConnection _connection;

    public async Task PublishAsync<T>(T @event) where T : class
    {
        using var channel = _connection.CreateModel();
        var json = JsonSerializer.Serialize(@event);
        var body = Encoding.UTF8.GetBytes(json);

        channel.BasicPublish(
            exchange: "events",
            routingKey: typeof(T).Name,
            body: body);

        await Task.CompletedTask;
    }
}

4. Presentation Layer

Handles HTTP requests and responses.

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IPlaceOrderHandler _placeOrder;
    private readonly IOrderQuery _orderQuery;

    public OrdersController(IPlaceOrderHandler placeOrder, IOrderQuery orderQuery)
    {
        _placeOrder = placeOrder;
        _orderQuery = orderQuery;
    }

    [HttpPost]
    public async Task<IActionResult> PlaceOrder([FromBody] PlaceOrderCommand command)
    {
        var result = await _placeOrder.HandleAsync(command);

        if (result.IsFailure)
            return BadRequest(result.Error);

        return CreatedAtAction(nameof(GetOrder), new { id = result.Value }, result.Value);
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetOrder(int id)
    {
        var order = await _orderQuery.GetByIdAsync(id);

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

        return Ok(order);
    }
}

Domain-Driven Design Basics

Clean Architecture pairs naturally with DDD concepts:

Aggregates

// Order is an aggregate root
public class Order
{
    private readonly List<OrderItem> _items = new();

    public int Id { get; private set; }
    public int CustomerId { get; private set; }
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
    public decimal Total => _items.Sum(i => i.Subtotal);

    // Aggregate ensures consistency
    public void AddItem(Product product, int quantity)
    {
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive");

        var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (existingItem != null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(new OrderItem(product.Id, product.Price, quantity));
        }
    }
}

// OrderItem is part of the Order aggregate, not accessed directly
public class OrderItem
{
    public int ProductId { get; private set; }
    public decimal Price { get; private set; }
    public int Quantity { get; private set; }
    public decimal Subtotal => Price * Quantity;

    internal OrderItem(int productId, decimal price, int quantity)
    {
        ProductId = productId;
        Price = price;
        Quantity = quantity;
    }

    internal void IncreaseQuantity(int amount)
    {
        Quantity += amount;
    }
}

Interview Tips

Tip 1: Explain Clean Architecture using the "layers of an onion" analogy - the core (domain) is protected, outer layers can be peeled away and replaced.

Tip 2: Emphasize testability - inner layers can be tested without any external dependencies, making unit testing fast and reliable.

Tip 3: When discussing trade-offs, mention that Clean Architecture can feel over-engineered for simple CRUD apps. It shines in complex business domains.

Common Interview Questions

  1. What problem does Clean Architecture solve?

    • Tight coupling to frameworks and databases, making systems hard to test and evolve. Clean Architecture makes the core business logic independent of technical details.
  2. How do you handle dependencies crossing layer boundaries?

    • Use dependency inversion. Define interfaces in inner layers, implement them in outer layers, inject via constructor.
  3. Where do you put validation logic?

    • Domain validation (business rules) in entities. Input validation (format, required fields) in Application or Presentation layer.
  4. How is Clean Architecture different from N-tier?

    • N-tier has dependencies flowing downward (UI → Business → Data). Clean Architecture has dependencies flowing inward toward domain, with Infrastructure depending on Application interfaces.
  5. When would you NOT use Clean Architecture?

    • Simple CRUD apps, prototypes, small microservices with minimal business logic. The overhead isn't justified without complex domain logic.
  6. How do you organize projects in a Clean Architecture solution?

    Solution
    ā”œā”€ā”€ Domain/
    ā”œā”€ā”€ Application/
    ā”œā”€ā”€ Infrastructure/
    └── Presentation.Api/
    
  7. What's the difference between Application and Domain services?

    • Domain services: business logic involving multiple entities. Application services: orchestrate use cases, coordinate domain objects, handle infrastructure concerns.