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
-
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.
-
How do you handle dependencies crossing layer boundaries?
- Use dependency inversion. Define interfaces in inner layers, implement them in outer layers, inject via constructor.
-
Where do you put validation logic?
- Domain validation (business rules) in entities. Input validation (format, required fields) in Application or Presentation layer.
-
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.
-
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.
-
How do you organize projects in a Clean Architecture solution?
Solution āāā Domain/ āāā Application/ āāā Infrastructure/ āāā Presentation.Api/ -
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.