Clean Architecture in Practice: A Beginner-Friendly Guide with C#


Clean Architecture has become one of the most popular architectural patterns in software development, and for good reason. It promises to create systems that are easy to understand, test, and maintain. However, it’s also one of the most misunderstood patterns—many developers think it’s just about organizing folders, when it’s really about creating a system that’s independent of frameworks, databases, and external concerns.

In this guide, we’ll explore Clean Architecture through practical C# examples, showing you how to implement it in real projects and avoid common pitfalls.

Why Clean Architecture Matters

Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is designed to solve a fundamental problem: how do we create software that can evolve and adapt over time without becoming a tangled mess?

The answer lies in the Dependency Rule: source code dependencies must point inward, toward higher-level policies. In simpler terms, your business logic should never depend on external concerns like databases, web frameworks, or third-party libraries.

The Problem with Traditional Architectures

Consider a typical web application where your business logic is tightly coupled to your database:

// Bad: Business logic mixed with data access
public class OrderService
{
    private readonly SqlConnection _connection;
    
    public async Task<Order> CreateOrder(OrderRequest request)
    {
        // Business logic mixed with database concerns
        var order = new Order
        {
            CustomerId = request.CustomerId,
            Items = request.Items,
            Total = request.Items.Sum(i => i.Price * i.Quantity),
            CreatedAt = DateTime.UtcNow
        };
        
        // Direct database dependency
        using (var cmd = _connection.CreateCommand())
        {
            cmd.CommandText = "INSERT INTO Orders (CustomerId, Total, CreatedAt) VALUES (@CustomerId, @Total, @CreatedAt)";
            cmd.Parameters.AddWithValue("@CustomerId", order.CustomerId);
            cmd.Parameters.AddWithValue("@Total", order.Total);
            cmd.Parameters.AddWithValue("@CreatedAt", order.CreatedAt);
            await cmd.ExecuteNonQueryAsync();
        }
        
        return order;
    }
}

This approach has several problems:

  • Hard to test: You need a real database to test business logic
  • Hard to change: Switching databases requires rewriting business logic
  • Hard to understand: Business rules are buried in data access code

Core Concepts of Clean Architecture

Clean Architecture is built around four main layers, each with a specific responsibility:

1. Entities (Domain Layer)

Entities represent your core business objects and contain the most important business rules.

// Domain/Entities/Order.cs
public class Order
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public List<OrderItem> Items { get; private set; }
    public decimal Total { get; private set; }
    public OrderStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }

    public Order(Guid customerId, List<OrderItem> items)
    {
        Id = Guid.NewGuid();
        CustomerId = customerId;
        Items = items ?? new List<OrderItem>();
        Total = CalculateTotal();
        Status = OrderStatus.Pending;
        CreatedAt = DateTime.UtcNow;
    }

    public void Confirm()
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Only pending orders can be confirmed");
        
        Status = OrderStatus.Confirmed;
    }

    public void Cancel()
    {
        if (Status == OrderStatus.Shipped)
            throw new InvalidOperationException("Shipped orders cannot be cancelled");
        
        Status = OrderStatus.Cancelled;
    }

    private decimal CalculateTotal()
    {
        return Items.Sum(item => item.Price * item.Quantity);
    }
}

public enum OrderStatus
{
    Pending,
    Confirmed,
    Shipped,
    Delivered,
    Cancelled
}

2. Use Cases (Application Layer)

Use cases orchestrate the flow of data between entities and external interfaces.

// Application/UseCases/CreateOrder/CreateOrderUseCase.cs
public class CreateOrderUseCase : ICreateOrderUseCase
{
    private readonly IOrderRepository _orderRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderUseCase(
        IOrderRepository orderRepository,
        ICustomerRepository customerRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _customerRepository = customerRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<CreateOrderResponse> ExecuteAsync(CreateOrderRequest request)
    {
        // Validate customer exists
        var customer = await _customerRepository.GetByIdAsync(request.CustomerId);
        if (customer == null)
            throw new CustomerNotFoundException(request.CustomerId);

        // Create order with business logic
        var orderItems = request.Items.Select(item => 
            new OrderItem(item.ProductId, item.Quantity, item.Price)).ToList();
        
        var order = new Order(request.CustomerId, orderItems);

        // Persist order
        await _orderRepository.AddAsync(order);
        await _unitOfWork.SaveChangesAsync();

        return new CreateOrderResponse
        {
            OrderId = order.Id,
            Total = order.Total,
            Status = order.Status
        };
    }
}

// Application/UseCases/CreateOrder/CreateOrderRequest.cs
public class CreateOrderRequest
{
    public Guid CustomerId { get; set; }
    public List<OrderItemRequest> Items { get; set; }
}

public class CreateOrderResponse
{
    public Guid OrderId { get; set; }
    public decimal Total { get; set; }
    public OrderStatus Status { get; set; }
}

3. Interfaces (Interface Adapters Layer)

Interfaces define contracts for external concerns like databases, APIs, and UI.

// Application/Interfaces/IOrderRepository.cs
public interface IOrderRepository
{
    Task<Order> GetByIdAsync(Guid id);
    Task<IEnumerable<Order>> GetByCustomerIdAsync(Guid customerId);
    Task AddAsync(Order order);
    Task UpdateAsync(Order order);
    Task DeleteAsync(Guid id);
}

// Application/Interfaces/IUnitOfWork.cs
public interface IUnitOfWork
{
    Task<int> SaveChangesAsync();
    Task BeginTransactionAsync();
    Task CommitTransactionAsync();
    Task RollbackTransactionAsync();
}

4. Infrastructure (Frameworks & Drivers Layer)

Infrastructure implements the interfaces defined in the application layer.

// Infrastructure/Data/Repositories/OrderRepository.cs
public class OrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;

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

    public async Task<Order> GetByIdAsync(Guid id)
    {
        var orderEntity = await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id);

        return orderEntity?.ToDomain();
    }

    public async Task<IEnumerable<Order>> GetByCustomerIdAsync(Guid customerId)
    {
        var orderEntities = await _context.Orders
            .Include(o => o.Items)
            .Where(o => o.CustomerId == customerId)
            .ToListAsync();

        return orderEntities.Select(o => o.ToDomain());
    }

    public async Task AddAsync(Order order)
    {
        var orderEntity = OrderEntity.FromDomain(order);
        await _context.Orders.AddAsync(orderEntity);
    }

    public async Task UpdateAsync(Order order)
    {
        var orderEntity = await _context.Orders.FindAsync(order.Id);
        if (orderEntity != null)
        {
            orderEntity.UpdateFromDomain(order);
        }
    }

    public async Task DeleteAsync(Guid id)
    {
        var orderEntity = await _context.Orders.FindAsync(id);
        if (orderEntity != null)
        {
            _context.Orders.Remove(orderEntity);
        }
    }
}

Project Structure

Here’s how to organize your Clean Architecture project:

CleanArchitecture/
├── src/
│   ├── Domain/                          # Entities and business rules
│   │   ├── Entities/
│   │   │   ├── Order.cs
│   │   │   ├── Customer.cs
│   │   │   └── OrderItem.cs
│   │   └── ValueObjects/
│   │       └── Money.cs
│   │
│   ├── Application/                     # Use cases and interfaces
│   │   ├── UseCases/
│   │   │   ├── CreateOrder/
│   │   │   │   ├── CreateOrderUseCase.cs
│   │   │   │   ├── CreateOrderRequest.cs
│   │   │   │   └── CreateOrderResponse.cs
│   │   │   └── GetOrders/
│   │   │       ├── GetOrdersUseCase.cs
│   │   │       └── GetOrdersRequest.cs
│   │   └── Interfaces/
│   │       ├── IOrderRepository.cs
│   │       ├── ICustomerRepository.cs
│   │       └── IUnitOfWork.cs
│   │
│   ├── Infrastructure/                  # External concerns
│   │   ├── Data/
│   │   │   ├── ApplicationDbContext.cs
│   │   │   ├── Repositories/
│   │   │   │   ├── OrderRepository.cs
│   │   │   │   └── CustomerRepository.cs
│   │   │   └── Entities/
│   │   │       ├── OrderEntity.cs
│   │   │       └── CustomerEntity.cs
│   │   └── Services/
│   │       └── EmailService.cs
│   │
│   └── WebApi/                          # Presentation layer
│       ├── Controllers/
│       │   └── OrdersController.cs
│       ├── Program.cs
│       └── DependencyInjection.cs

└── tests/
    ├── Domain.Tests/
    ├── Application.Tests/
    └── Infrastructure.Tests/

Dependency Inversion in Action

The key to Clean Architecture is dependency inversion. Notice how our use case depends on abstractions, not concrete implementations:

// Application/UseCases/CreateOrder/CreateOrderUseCase.cs
public class CreateOrderUseCase : ICreateOrderUseCase
{
    // Dependencies are interfaces, not concrete classes
    private readonly IOrderRepository _orderRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderUseCase(
        IOrderRepository orderRepository,        // Interface
        ICustomerRepository customerRepository,  // Interface
        IUnitOfWork unitOfWork)                  // Interface
    {
        _orderRepository = orderRepository;
        _customerRepository = customerRepository;
        _unitOfWork = unitOfWork;
    }
}

This allows us to easily swap implementations:

// WebApi/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        // Register use cases
        services.AddScoped<ICreateOrderUseCase, CreateOrderUseCase>();
        services.AddScoped<IGetOrdersUseCase, GetOrdersUseCase>();

        // Register repositories
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<ICustomerRepository, CustomerRepository>();
        services.AddScoped<IUnitOfWork, UnitOfWork>();

        return services;
    }
}

Common Mistakes to Avoid

1. Mixing Infrastructure with Domain

// Bad: Domain entity with infrastructure concerns
public class Order
{
    public int Id { get; set; }  // Database ID in domain
    public string CustomerName { get; set; }  // UI concern in domain
    
    public void Save()  // Infrastructure concern in domain
    {
        // Database logic here
    }
}

// Good: Pure domain entity
public class Order
{
    public Guid Id { get; private set; }  // Domain ID
    public Guid CustomerId { get; private set; }  // Domain relationship
    
    public void Confirm()  // Pure business logic
    {
        // Business rules only
    }
}

2. Treating Clean Architecture as Just Folder Structure

Clean Architecture isn’t about organizing files—it’s about organizing dependencies. You can have the right folder structure but still violate the dependency rule.

3. Over-Engineering Simple Applications

Don’t apply Clean Architecture to every project. For simple CRUD applications, it might be overkill. Use it when you need:

  • Complex business rules
  • Multiple external dependencies
  • Long-term maintainability
  • Team scalability

Benefits in Real Projects

1. Easier Testing

With Clean Architecture, you can test business logic without external dependencies:

[Test]
public async Task CreateOrder_WithValidRequest_ShouldCreateOrder()
{
    // Arrange
    var mockOrderRepo = new Mock<IOrderRepository>();
    var mockCustomerRepo = new Mock<ICustomerRepository>();
    var mockUnitOfWork = new Mock<IUnitOfWork>();
    
    var customer = new Customer(Guid.NewGuid(), "John Doe");
    mockCustomerRepo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>()))
        .ReturnsAsync(customer);
    
    var useCase = new CreateOrderUseCase(
        mockOrderRepo.Object,
        mockCustomerRepo.Object,
        mockUnitOfWork.Object);
    
    var request = new CreateOrderRequest
    {
        CustomerId = customer.Id,
        Items = new List<OrderItemRequest>
        {
            new() { ProductId = Guid.NewGuid(), Quantity = 2, Price = 10.00m }
        }
    };
    
    // Act
    var result = await useCase.ExecuteAsync(request);
    
    // Assert
    Assert.That(result.OrderId, Is.Not.EqualTo(Guid.Empty));
    Assert.That(result.Total, Is.EqualTo(20.00m));
    mockOrderRepo.Verify(r => r.AddAsync(It.IsAny<Order>()), Times.Once);
}

2. Flexibility in Replacing Databases

Want to switch from SQL Server to PostgreSQL? Just implement a new repository:

public class PostgreSqlOrderRepository : IOrderRepository
{
    // PostgreSQL-specific implementation
    // Business logic remains unchanged
}

3. Scaling to Microservices

Clean Architecture makes it easier to extract services:

// Extract to Order Service
public class OrderService
{
    private readonly ICreateOrderUseCase _createOrderUseCase;
    private readonly IGetOrdersUseCase _getOrdersUseCase;
    
    public async Task<CreateOrderResponse> CreateOrder(CreateOrderRequest request)
    {
        return await _createOrderUseCase.ExecuteAsync(request);
    }
}

Implementing CQRS with Clean Architecture

CQRS (Command Query Responsibility Segregation) works beautifully with Clean Architecture:

// Commands (Write operations)
public class CreateOrderCommand : IRequest<CreateOrderResponse>
{
    public Guid CustomerId { get; set; }
    public List<OrderItemDto> Items { get; set; }
}

public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, CreateOrderResponse>
{
    private readonly ICreateOrderUseCase _createOrderUseCase;
    
    public CreateOrderCommandHandler(ICreateOrderUseCase createOrderUseCase)
    {
        _createOrderUseCase = createOrderUseCase;
    }
    
    public async Task<CreateOrderResponse> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
    {
        var useCaseRequest = new CreateOrderRequest
        {
            CustomerId = request.CustomerId,
            Items = request.Items.Select(i => new OrderItemRequest
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                Price = i.Price
            }).ToList()
        };
        
        return await _createOrderUseCase.ExecuteAsync(useCaseRequest);
    }
}

// Queries (Read operations)
public class GetOrdersQuery : IRequest<IEnumerable<OrderDto>>
{
    public Guid CustomerId { get; set; }
}

public class GetOrdersQueryHandler : IRequestHandler<GetOrdersQuery, IEnumerable<OrderDto>>
{
    private readonly IOrderRepository _orderRepository;
    
    public GetOrdersQueryHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }
    
    public async Task<IEnumerable<OrderDto>> Handle(GetOrdersQuery request, CancellationToken cancellationToken)
    {
        var orders = await _orderRepository.GetByCustomerIdAsync(request.CustomerId);
        return orders.Select(o => new OrderDto
        {
            Id = o.Id,
            CustomerId = o.CustomerId,
            Total = o.Total,
            Status = o.Status,
            CreatedAt = o.CreatedAt
        });
    }
}

Conclusion

Clean Architecture isn’t just another design pattern—it’s a way of thinking about software that prioritizes maintainability, testability, and flexibility. While it requires more upfront planning, it pays dividends as your application grows and evolves.

Key Takeaways:

  1. Dependencies point inward: Business logic never depends on external concerns
  2. Entities contain business rules: Keep them pure and framework-agnostic
  3. Use cases orchestrate: They coordinate between entities and external interfaces
  4. Interfaces define contracts: They allow for easy swapping of implementations
  5. Infrastructure implements: External concerns are isolated in the outer layer

Next Steps:

  • Start with a small domain and gradually expand
  • Focus on the dependency rule, not just folder structure
  • Write tests to ensure your architecture is working correctly
  • Don’t over-engineer—use Clean Architecture when it adds value

Ready to try Clean Architecture in your next project? Start with a simple domain like the order management system we built here, and you’ll quickly see the benefits of this approach.


Have you implemented Clean Architecture in your projects? What challenges did you face, and how did you overcome them? Share your experiences in the comments below!

Join the Discussion

Have thoughts on this article? Share your insights and engage with the community.