LinhGo Labs
LinhGo Labs

Contents

Common Clean Architecture Mistakes in .NET (And How to Fix Them)

Common Clean Architecture Mistakes in .NET (And How to Fix Them)

Contents

Clean Architecture promises maintainability, testability, and flexibility. But without careful implementation, it can become a nightmare of over-engineering, broken abstractions, and violated principles. Let’s explore the most common mistakes and learn how to avoid them.

๐Ÿ“š New to Clean Architecture? Check out our comprehensive guides:


Clean Architecture is powerful but easy to misuse. Common issues include:

  • โŒ Breaking the Dependency Rule
  • โŒ Creating anemic domain models
  • โŒ Over-engineering simple features
  • โŒ Leaking infrastructure concerns into domain
  • โŒ Misusing repositories and DTOs

Let’s dive into each mistake with real-world examples and solutions.


The most fundamental rule of Clean Architecture is: Dependencies point inward. Inner layers should NEVER depend on outer layers.

// โŒ WRONG: Domain/Entities/Order.cs
using Microsoft.EntityFrameworkCore;  // โŒ Infrastructure dependency!
using Infrastructure.Persistence;      // โŒ Outer layer reference!

public class Order
{
    public Guid Id { get; set; }
    public string Status { get; set; }
    
    // โŒ Domain entity knows about DbContext
    public async Task SaveToDatabase(ApplicationDbContext context)
    {
        context.Orders.Update(this);
        await context.SaveChangesAsync();
    }
    
    // โŒ Domain entity knows about specific data access
    public static async Task<Order?> LoadFromDatabase(
        ApplicationDbContext context, 
        Guid id)
    {
        return await context.Orders.FindAsync(id);
    }
}

Why This Is Wrong:

  • Domain layer now depends on Entity Framework Core
  • Can’t change database without modifying domain
  • Can’t test domain logic without database
  • Violates Dependency Inversion Principle
// โœ… CORRECT: Domain/Entities/Order.cs
// No infrastructure dependencies!
public class Order
{
    public Guid Id { get; private set; }
    public OrderStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }
    
    private readonly List<OrderItem> _items = new();
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
    
    private Order() { } // For EF Core
    
    public static Order Create(Guid customerId)
    {
        return new Order
        {
            Id = Guid.NewGuid(),
            Status = OrderStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };
    }
    
    public void AddItem(Product product, int quantity)
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Cannot modify confirmed order");
            
        _items.Add(new OrderItem(product, quantity));
    }
    
    public void Confirm()
    {
        if (!_items.Any())
            throw new InvalidOperationException("Cannot confirm empty order");
            
        Status = OrderStatus.Confirmed;
    }
}

// โœ… Application layer defines interface
// Application/Interfaces/IOrderRepository.cs
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id);
    Task AddAsync(Order order);
    Task UpdateAsync(Order order);
}

// โœ… Infrastructure implements interface
// Infrastructure/Persistence/OrderRepository.cs
public class OrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;
    
    public OrderRepository(ApplicationDbContext context)
    {
        _context = context;
    }
    
    public async Task<Order?> GetByIdAsync(Guid id)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id);
    }
    
    public async Task AddAsync(Order order)
    {
        await _context.Orders.AddAsync(order);
    }
    
    public async Task UpdateAsync(Order order)
    {
        _context.Orders.Update(order);
    }
}

Key Fixes:

  • โœ… Domain has zero infrastructure dependencies
  • โœ… Repository interface defined in Application layer
  • โœ… Implementation in Infrastructure layer
  • โœ… Can test domain logic in isolation
  • โœ… Can swap database without touching domain

An anemic domain model is one that contains only data (properties) with no behavior (methods). All business logic ends up in services, defeating the purpose of Clean Architecture.

// โŒ Anemic Domain Entity - Just a data bag
public class BankAccount
{
    public Guid Id { get; set; }
    public decimal Balance { get; set; }  // โŒ Public setter
    public bool IsActive { get; set; }    // โŒ Public setter
    public DateTime CreatedAt { get; set; }
}

// โŒ All business logic in service layer
public class BankAccountService
{
    private readonly IRepository<BankAccount> _repository;
    
    // โŒ Business rules scattered in service
    public async Task Deposit(Guid accountId, decimal amount)
    {
        var account = await _repository.GetByIdAsync(accountId);
        
        // โŒ Validation logic in service
        if (amount <= 0)
            throw new ArgumentException("Amount must be positive");
            
        if (!account.IsActive)
            throw new InvalidOperationException("Account is not active");
        
        // โŒ Business logic in service
        account.Balance += amount;
        
        await _repository.UpdateAsync(account);
    }
    
    // โŒ More business rules in service
    public async Task Withdraw(Guid accountId, decimal amount)
    {
        var account = await _repository.GetByIdAsync(accountId);
        
        if (amount <= 0)
            throw new ArgumentException("Amount must be positive");
            
        if (!account.IsActive)
            throw new InvalidOperationException("Account is not active");
            
        // โŒ Business rule in service
        if (account.Balance < amount)
            throw new InvalidOperationException("Insufficient funds");
        
        account.Balance -= amount;
        
        await _repository.UpdateAsync(account);
    }
}

Problems:

  • Business rules scattered across services
  • Easy to bypass validation
  • Hard to test business logic
  • Duplicate validation code
  • Entity can be in invalid state
// โœ… Rich Domain Entity - Encapsulates behavior
public class BankAccount
{
    public Guid Id { get; private set; }  // โœ… Private setters
    public decimal Balance { get; private set; }
    public bool IsActive { get; private set; }
    public DateTime CreatedAt { get; private set; }
    
    private readonly List<Transaction> _transactions = new();
    public IReadOnlyList<Transaction> Transactions => _transactions.AsReadOnly();
    
    private BankAccount() { } // For EF Core
    
    // โœ… Factory method with validation
    public static BankAccount Create(Guid customerId, decimal initialDeposit)
    {
        if (initialDeposit < 0)
            throw new ArgumentException("Initial deposit cannot be negative");
            
        var account = new BankAccount
        {
            Id = Guid.NewGuid(),
            Balance = initialDeposit,
            IsActive = true,
            CreatedAt = DateTime.UtcNow
        };
        
        if (initialDeposit > 0)
        {
            account.AddTransaction(TransactionType.Deposit, initialDeposit);
        }
        
        return account;
    }
    
    // โœ… Business logic in domain
    public void Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Amount must be positive");
            
        EnsureAccountIsActive();
        
        Balance += amount;
        AddTransaction(TransactionType.Deposit, amount);
    }
    
    // โœ… Business logic in domain
    public void Withdraw(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Amount must be positive");
            
        EnsureAccountIsActive();
        
        if (Balance < amount)
            throw new InvalidOperationException(
                $"Insufficient funds. Available: {Balance}, Requested: {amount}"
            );
        
        Balance -= amount;
        AddTransaction(TransactionType.Withdrawal, amount);
    }
    
    public void Deactivate()
    {
        if (Balance > 0)
            throw new InvalidOperationException(
                "Cannot deactivate account with positive balance"
            );
            
        IsActive = false;
    }
    
    public void Activate() => IsActive = true;
    
    // โœ… Encapsulated validation
    private void EnsureAccountIsActive()
    {
        if (!IsActive)
            throw new InvalidOperationException("Account is not active");
    }
    
    private void AddTransaction(TransactionType type, decimal amount)
    {
        _transactions.Add(new Transaction
        {
            Id = Guid.NewGuid(),
            Type = type,
            Amount = amount,
            Timestamp = DateTime.UtcNow
        });
    }
    
    // โœ… Calculated properties
    public decimal GetTotalDeposits() 
        => _transactions
            .Where(t => t.Type == TransactionType.Deposit)
            .Sum(t => t.Amount);
            
    public decimal GetTotalWithdrawals()
        => _transactions
            .Where(t => t.Type == TransactionType.Withdrawal)
            .Sum(t => t.Amount);
}

// โœ… Application service is now thin
public class BankAccountService
{
    private readonly IBankAccountRepository _repository;
    private readonly IUnitOfWork _unitOfWork;
    
    // โœ… Service only orchestrates
    public async Task DepositAsync(Guid accountId, decimal amount)
    {
        var account = await _repository.GetByIdAsync(accountId);
        
        if (account == null)
            throw new NotFoundException($"Account {accountId} not found");
        
        // โœ… Domain handles all business logic
        account.Deposit(amount);
        
        await _unitOfWork.SaveChangesAsync();
    }
    
    public async Task WithdrawAsync(Guid accountId, decimal amount)
    {
        var account = await _repository.GetByIdAsync(accountId);
        
        if (account == null)
            throw new NotFoundException($"Account {accountId} not found");
        
        // โœ… Domain handles all business logic
        account.Withdraw(amount);
        
        await _unitOfWork.SaveChangesAsync();
    }
}

Key Improvements:

  • โœ… Business logic in domain where it belongs
  • โœ… Impossible to bypass validation (private setters)
  • โœ… Self-documenting - methods show what the entity can do
  • โœ… Easy to test - test entity methods directly
  • โœ… No duplicate code - validation in one place
  • โœ… Service layer is thin orchestration

Returning domain entities directly from controllers creates tight coupling between internal domain structure and external API contracts.

// โŒ Domain Entity
public class User
{
    public Guid Id { get; set; }
    public string Username { get; set; }
    public string PasswordHash { get; set; }  // โŒ Sensitive data!
    public string Email { get; set; }
    public List<Order> Orders { get; set; }   // โŒ Complex navigation
    public DateTime CreatedAt { get; set; }
    public DateTime? LastLoginAt { get; set; }
}

// โŒ Controller exposing entity directly
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserRepository _repository;
    
    // โŒ Returns domain entity directly
    [HttpGet("{id}")]
    public async Task<ActionResult<User>> GetUser(Guid id)
    {
        var user = await _repository.GetByIdAsync(id);
        
        // โŒ Exposing sensitive data and internal structure
        return Ok(user);
    }
    
    // โŒ Accepts domain entity as input
    [HttpPost]
    public async Task<ActionResult<User>> CreateUser([FromBody] User user)
    {
        await _repository.AddAsync(user);
        return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
    }
}

Problems:

  • โŒ Security risk: Exposes PasswordHash and other sensitive data
  • โŒ Tight coupling: API contract depends on domain structure
  • โŒ Circular references: Navigation properties cause serialization issues
  • โŒ Over-fetching: Returns more data than needed
  • โŒ Breaking changes: Domain changes break API contracts
// โœ… Domain Entity (unchanged)
public class User
{
    public Guid Id { get; private set; }
    public string Username { get; private set; }
    public string PasswordHash { get; private set; }
    public string Email { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime? LastLoginAt { get; private set; }
    
    private readonly List<Order> _orders = new();
    public IReadOnlyList<Order> Orders => _orders.AsReadOnly();
    
    // Domain methods...
}

// โœ… Application Layer DTOs
public record UserDto
{
    public Guid Id { get; init; }
    public string Username { get; init; }
    public string Email { get; init; }
    public DateTime CreatedAt { get; init; }
    public int OrderCount { get; init; }
}

public record CreateUserDto
{
    public string Username { get; init; }
    public string Email { get; init; }
    public string Password { get; init; }  // Plain password, will be hashed
}

public record UserDetailDto
{
    public Guid Id { get; init; }
    public string Username { get; init; }
    public string Email { get; init; }
    public DateTime CreatedAt { get; init; }
    public DateTime? LastLoginAt { get; init; }
    public List<OrderSummaryDto> RecentOrders { get; init; }
}

// โœ… Application Service with mapping
public class UserService
{
    private readonly IUserRepository _repository;
    private readonly IPasswordHasher _passwordHasher;
    private readonly IUnitOfWork _unitOfWork;
    
    public async Task<UserDto> GetUserAsync(Guid id)
    {
        var user = await _repository.GetByIdAsync(id);
        
        if (user == null)
            throw new NotFoundException($"User {id} not found");
        
        // โœ… Map to DTO
        return new UserDto
        {
            Id = user.Id,
            Username = user.Username,
            Email = user.Email,
            CreatedAt = user.CreatedAt,
            OrderCount = user.Orders.Count
        };
    }
    
    public async Task<UserDto> CreateUserAsync(CreateUserDto dto)
    {
        // โœ… Hash password before creating entity
        var passwordHash = _passwordHasher.Hash(dto.Password);
        
        // โœ… Use domain factory method
        var user = User.Create(dto.Username, dto.Email, passwordHash);
        
        await _repository.AddAsync(user);
        await _unitOfWork.SaveChangesAsync();
        
        // โœ… Return DTO, not entity
        return new UserDto
        {
            Id = user.Id,
            Username = user.Username,
            Email = user.Email,
            CreatedAt = user.CreatedAt,
            OrderCount = 0
        };
    }
}

// โœ… Controller using DTOs
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly UserService _userService;
    
    [HttpGet("{id}")]
    [ProducesResponseType(typeof(UserDto), 200)]
    [ProducesResponseType(404)]
    public async Task<ActionResult<UserDto>> GetUser(Guid id)
    {
        try
        {
            var user = await _userService.GetUserAsync(id);
            return Ok(user);
        }
        catch (NotFoundException)
        {
            return NotFound();
        }
    }
    
    [HttpPost]
    [ProducesResponseType(typeof(UserDto), 201)]
    [ProducesResponseType(400)]
    public async Task<ActionResult<UserDto>> CreateUser(
        [FromBody] CreateUserDto dto)
    {
        var user = await _userService.CreateUserAsync(dto);
        return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
    }
}

Key Improvements:

  • โœ… Security: Sensitive data never exposed
  • โœ… Flexibility: Can shape API response independently
  • โœ… Versioning: Can maintain multiple DTO versions
  • โœ… Performance: Return only needed data
  • โœ… Stability: Domain changes don’t break API

Using primitives for domain concepts that have validation rules and behavior leads to scattered validation and potential bugs.

// โŒ Using primitives everywhere
public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }        // โŒ Just a decimal
    public string Currency { get; set; }      // โŒ Separate string
    public string Email { get; set; }         // โŒ Just a string
    public string Phone { get; set; }         // โŒ Just a string
}

// โŒ Validation scattered everywhere
public class ProductService
{
    public async Task CreateProduct(string name, decimal price, string currency)
    {
        // โŒ Validation repeated in multiple places
        if (price < 0)
            throw new ArgumentException("Price cannot be negative");
            
        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException("Currency is required");
            
        if (currency.Length != 3)
            throw new ArgumentException("Currency must be 3 characters");
        
        // Easy to mix up price and currency
        var product = new Product
        {
            Price = price,
            Currency = currency.ToUpperInvariant()
        };
    }
    
    public decimal CalculateTotal(decimal price1, string curr1, 
                                   decimal price2, string curr2)
    {
        // โŒ Easy to make mistakes
        if (curr1 != curr2)
            throw new InvalidOperationException("Different currencies");
            
        return price1 + price2;
    }
}

Problems:

  • โŒ Validation duplicated everywhere
  • โŒ Easy to pass wrong parameters (decimal, string - which is which?)
  • โŒ No encapsulation of business rules
  • โŒ Can create invalid combinations
// โœ… Money Value Object
public sealed class Money : ValueObject
{
    public decimal Amount { get; private set; }
    public Currency Currency { get; private set; }
    
    private Money(decimal amount, Currency currency)
    {
        Amount = amount;
        Currency = currency;
    }
    
    // โœ… Factory method with validation
    public static Money Create(decimal amount, Currency currency)
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative");
            
        return new Money(amount, currency);
    }
    
    // โœ… Domain operations
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException(
                $"Cannot add {other.Currency} to {Currency}"
            );
            
        return new Money(Amount + other.Amount, Currency);
    }
    
    public Money Multiply(decimal multiplier)
    {
        return new Money(Amount * multiplier, Currency);
    }
    
    // โœ… Value equality
    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Amount;
        yield return Currency;
    }
    
    public override string ToString() => $"{Amount:N2} {Currency}";
}

// โœ… Currency Value Object (could also be an enum)
public sealed class Currency : ValueObject
{
    public string Code { get; private set; }
    
    private Currency(string code)
    {
        Code = code;
    }
    
    public static Currency USD => new("USD");
    public static Currency EUR => new("EUR");
    public static Currency GBP => new("GBP");
    
    public static Currency FromCode(string code)
    {
        if (string.IsNullOrWhiteSpace(code))
            throw new ArgumentException("Currency code required");
            
        if (code.Length != 3)
            throw new ArgumentException("Currency code must be 3 characters");
            
        return new Currency(code.ToUpperInvariant());
    }
    
    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Code;
    }
    
    public override string ToString() => Code;
}

// โœ… Email Value Object
public sealed class Email : ValueObject
{
    public string Value { get; private set; }
    
    private Email(string value)
    {
        Value = value;
    }
    
    public static Email Create(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
            throw new ArgumentException("Email is required");
            
        // โœ… Validation in one place
        if (!IsValidEmail(email))
            throw new ArgumentException($"Invalid email: {email}");
            
        return new Email(email.ToLowerInvariant());
    }
    
    private static bool IsValidEmail(string email)
    {
        try
        {
            var addr = new System.Net.Mail.MailAddress(email);
            return addr.Address == email;
        }
        catch
        {
            return false;
        }
    }
    
    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Value;
    }
    
    public override string ToString() => Value;
}

// โœ… Product using Value Objects
public class Product
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public Money Price { get; private set; }    // โœ… Type-safe
    public Email ContactEmail { get; private set; }
    
    public static Product Create(string name, Money price, Email email)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Name is required");
            
        return new Product
        {
            Id = Guid.NewGuid(),
            Name = name,
            Price = price,  // โœ… Already validated
            ContactEmail = email  // โœ… Already validated
        };
    }
    
    public void UpdatePrice(Money newPrice)
    {
        // โœ… Type-safe - can't accidentally pass wrong type
        Price = newPrice;
    }
    
    public Money CalculateDiscountedPrice(decimal discountPercent)
    {
        // โœ… Rich operations on value objects
        var discountMultiplier = (100 - discountPercent) / 100;
        return Price.Multiply(discountMultiplier);
    }
}

// โœ… Service is now cleaner
public class ProductService
{
    public async Task<ProductDto> CreateProduct(
        string name, 
        decimal priceAmount, 
        string currencyCode,
        string emailAddress)
    {
        // โœ… Create value objects (validation happens here)
        var currency = Currency.FromCode(currencyCode);
        var price = Money.Create(priceAmount, currency);
        var email = Email.Create(emailAddress);
        
        // โœ… Create product with validated value objects
        var product = Product.Create(name, price, email);
        
        await _repository.AddAsync(product);
        await _unitOfWork.SaveChangesAsync();
        
        return MapToDto(product);
    }
}

Key Benefits:

  • โœ… Single validation point - validated in value object constructor
  • โœ… Type safety - can’t mix up parameters
  • โœ… Encapsulation - behavior with data
  • โœ… Testability - easy to test value objects in isolation
  • โœ… Reusability - use Money everywhere in domain
  • โœ… Expressiveness - Money is more meaningful than decimal

Repositories that are too generic or expose IQueryable leak persistence concerns into the application layer.

// โŒ Generic repository exposing IQueryable
public interface IRepository<T> where T : class
{
    IQueryable<T> GetAll();  // โŒ Exposes IQueryable
    IQueryable<T> Find(Expression<Func<T, bool>> predicate);  // โŒ LINQ in app layer
    Task<T> GetByIdAsync(Guid id);
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(T entity);
}

// โŒ Application layer doing database queries
public class OrderService
{
    private readonly IRepository<Order> _repository;
    
    public async Task<List<OrderDto>> GetCustomerOrders(Guid customerId)
    {
        // โŒ Writing LINQ queries in service layer
        var orders = await _repository
            .GetAll()
            .Where(o => o.CustomerId == customerId)
            .Include(o => o.Items)  // โŒ EF Core specific!
            .ThenInclude(i => i.Product)
            .OrderByDescending(o => o.CreatedAt)
            .Take(10)
            .ToListAsync();
            
        return orders.Select(MapToDto).ToList();
    }
    
    public async Task<decimal> GetTotalRevenue(DateTime from, DateTime to)
    {
        // โŒ Complex query logic in service
        return await _repository
            .GetAll()
            .Where(o => o.CreatedAt >= from && o.CreatedAt <= to)
            .Where(o => o.Status == OrderStatus.Completed)
            .SelectMany(o => o.Items)
            .SumAsync(i => i.Price * i.Quantity);
    }
}

Problems:

  • โŒ Leaking abstraction: Application knows about Include, ThenInclude
  • โŒ Tight coupling: Coupled to Entity Framework
  • โŒ Hard to test: Can’t easily mock IQueryable
  • โŒ Performance issues: Easy to create N+1 queries
  • โŒ Breaking encapsulation: Data access logic scattered
// โœ… Specific repository interface with meaningful methods
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id);
    Task<Order?> GetByIdWithItemsAsync(Guid id);  // โœ… Explicit include
    Task<IReadOnlyList<Order>> GetCustomerOrdersAsync(
        Guid customerId, 
        int pageSize = 10
    );
    Task<IReadOnlyList<Order>> GetOrdersByStatusAsync(OrderStatus status);
    Task<IReadOnlyList<Order>> GetOrdersInDateRangeAsync(
        DateTime from, 
        DateTime to
    );
    Task<decimal> GetTotalRevenueAsync(DateTime from, DateTime to);
    Task<bool> ExistsAsync(Guid id);
    Task AddAsync(Order order);
    Task UpdateAsync(Order order);
    Task DeleteAsync(Order order);
}

// โœ… Repository implementation with optimized queries
public class OrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;
    
    public OrderRepository(ApplicationDbContext context)
    {
        _context = context;
    }
    
    public async Task<Order?> GetByIdAsync(Guid id)
    {
        return await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == id);
    }
    
    // โœ… Explicit method for including related data
    public async Task<Order?> GetByIdWithItemsAsync(Guid id)
    {
        return await _context.Orders
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
            .FirstOrDefaultAsync(o => o.Id == id);
    }
    
    // โœ… Domain-specific query encapsulated
    public async Task<IReadOnlyList<Order>> GetCustomerOrdersAsync(
        Guid customerId, 
        int pageSize = 10)
    {
        return await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .Include(o => o.Items)
            .OrderByDescending(o => o.CreatedAt)
            .Take(pageSize)
            .ToListAsync();
    }
    
    // โœ… Complex business query in repository
    public async Task<decimal> GetTotalRevenueAsync(
        DateTime from, 
        DateTime to)
    {
        return await _context.Orders
            .Where(o => o.CreatedAt >= from && o.CreatedAt <= to)
            .Where(o => o.Status == OrderStatus.Completed)
            .SelectMany(o => o.Items)
            .SumAsync(i => i.TotalPrice);
    }
    
    public async Task AddAsync(Order order)
    {
        await _context.Orders.AddAsync(order);
    }
    
    public async Task UpdateAsync(Order order)
    {
        _context.Orders.Update(order);
    }
}

// โœ… Application service is now cleaner
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IUnitOfWork _unitOfWork;
    
    // โœ… Simple method call, no query logic
    public async Task<List<OrderDto>> GetCustomerOrdersAsync(Guid customerId)
    {
        var orders = await _repository.GetCustomerOrdersAsync(customerId);
        return orders.Select(MapToDto).ToList();
    }
    
    // โœ… Repository handles the complexity
    public async Task<decimal> GetTotalRevenueAsync(DateTime from, DateTime to)
    {
        return await _repository.GetTotalRevenueAsync(from, to);
    }
    
    // โœ… Business logic, not data access logic
    public async Task CompleteOrderAsync(Guid orderId)
    {
        var order = await _repository.GetByIdWithItemsAsync(orderId);
        
        if (order == null)
            throw new NotFoundException($"Order {orderId} not found");
        
        // โœ… Domain method handles business rules
        order.Complete();
        
        await _unitOfWork.SaveChangesAsync();
    }
}

Key Improvements:

  • โœ… Encapsulation: Query logic hidden in repository
  • โœ… Testability: Easy to mock specific methods
  • โœ… Performance: Optimized queries in one place
  • โœ… Abstraction: Application doesn’t know about EF Core
  • โœ… Maintainability: Change query logic in one place

Controllers should only handle HTTP concerns. Business logic in controllers makes testing difficult and leads to code duplication.

// โŒ Controller with business logic
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly IEmailService _emailService;
    
    [HttpPost]
    public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
    {
        // โŒ Validation in controller
        if (string.IsNullOrWhiteSpace(request.CustomerName))
            return BadRequest("Customer name is required");
            
        if (request.Items == null || !request.Items.Any())
            return BadRequest("Order must have at least one item");
        
        // โŒ Business logic in controller
        var customer = await _context.Customers
            .FirstOrDefaultAsync(c => c.Id == request.CustomerId);
            
        if (customer == null)
            return NotFound("Customer not found");
        
        // โŒ Database access directly in controller
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = request.CustomerId,
            CreatedAt = DateTime.UtcNow,
            Status = "Pending"
        };
        
        // โŒ More business logic
        decimal total = 0;
        foreach (var item in request.Items)
        {
            var product = await _context.Products.FindAsync(item.ProductId);
            
            if (product == null)
                return BadRequest($"Product {item.ProductId} not found");
                
            if (product.Stock < item.Quantity)
                return BadRequest($"Insufficient stock for {product.Name}");
            
            total += product.Price * item.Quantity;
            product.Stock -= item.Quantity;
            
            order.Items.Add(new OrderItem
            {
                ProductId = item.ProductId,
                Quantity = item.Quantity,
                Price = product.Price
            });
        }
        
        order.TotalAmount = total;
        
        // โŒ More database operations
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
        
        // โŒ Additional business logic (email)
        await _emailService.SendOrderConfirmationAsync(customer.Email, order);
        
        return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
    }
}

Problems:

  • โŒ Untestable: Hard to test without HTTP infrastructure
  • โŒ Duplication: Logic repeated in other endpoints
  • โŒ SRP violation: Controller does too much
  • โŒ Hard to reuse: Logic tied to HTTP
  • โŒ Difficult to maintain: Business rules scattered
// โœ… Application Service handles business logic
public class OrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly IEmailService _emailService;
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<OrderService> _logger;
    
    public async Task<OrderDto> CreateOrderAsync(CreateOrderCommand command)
    {
        // โœ… Business validation
        var customer = await _customerRepository.GetByIdAsync(command.CustomerId);
        if (customer == null)
            throw new NotFoundException($"Customer {command.CustomerId} not found");
        
        // โœ… Use domain factory method
        var order = Order.Create(customer.Id, customer.Name);
        
        // โœ… Business logic in domain or service
        foreach (var item in command.Items)
        {
            var product = await _productRepository.GetByIdAsync(item.ProductId);
            
            if (product == null)
                throw new NotFoundException($"Product {item.ProductId} not found");
            
            // โœ… Domain method handles business rules
            order.AddItem(product, item.Quantity);
            
            // โœ… Domain method handles stock reduction
            product.RemoveStock(item.Quantity);
        }
        
        // โœ… Persist changes
        await _orderRepository.AddAsync(order);
        await _unitOfWork.SaveChangesAsync();
        
        // โœ… Send notification
        try
        {
            await _emailService.SendOrderConfirmationAsync(customer.Email, order.Id);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to send order confirmation email");
            // Don't fail the order creation if email fails
        }
        
        return MapToDto(order);
    }
}

// โœ… Thin controller
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly OrderService _orderService;
    private readonly ILogger<OrdersController> _logger;
    
    public OrdersController(
        OrderService orderService,
        ILogger<OrdersController> logger)
    {
        _orderService = orderService;
        _logger = logger;
    }
    
    /// <summary>
    /// Create a new order
    /// </summary>
    [HttpPost]
    [ProducesResponseType(typeof(OrderDto), 201)]
    [ProducesResponseType(400)]
    [ProducesResponseType(404)]
    public async Task<ActionResult<OrderDto>> CreateOrder(
        [FromBody] CreateOrderCommand command)
    {
        try
        {
            // โœ… Controller only orchestrates
            var order = await _orderService.CreateOrderAsync(command);
            
            return CreatedAtAction(
                nameof(GetOrder), 
                new { id = order.Id }, 
                order
            );
        }
        catch (NotFoundException ex)
        {
            _logger.LogWarning(ex, "Resource not found");
            return NotFound(ex.Message);
        }
        catch (InvalidOperationException ex)
        {
            _logger.LogWarning(ex, "Business rule violation");
            return BadRequest(ex.Message);
        }
        catch (ArgumentException ex)
        {
            _logger.LogWarning(ex, "Invalid argument");
            return BadRequest(ex.Message);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unexpected error creating order");
            return StatusCode(500, "An error occurred while creating the order");
        }
    }
    
    [HttpGet("{id}")]
    [ProducesResponseType(typeof(OrderDto), 200)]
    [ProducesResponseType(404)]
    public async Task<ActionResult<OrderDto>> GetOrder(Guid id)
    {
        try
        {
            var order = await _orderService.GetOrderAsync(id);
            return Ok(order);
        }
        catch (NotFoundException)
        {
            return NotFound();
        }
    }
}

Key Improvements:

  • โœ… Separation: Business logic in service, HTTP in controller
  • โœ… Testability: Can test service without HTTP
  • โœ… Reusability: Service can be called from anywhere
  • โœ… Maintainability: Business rules in one place
  • โœ… SRP: Controller handles HTTP, service handles business

Applying Clean Architecture to every feature, no matter how simple, creates unnecessary complexity.

// โŒ Over-engineered for a simple lookup table

// Domain Layer - Overkill for simple data
public class Country
{
    public Guid Id { get; private set; }
    public string Code { get; private set; }
    public string Name { get; private set; }
    
    private Country() { }
    
    public static Country Create(string code, string name)
    {
        if (string.IsNullOrWhiteSpace(code))
            throw new ArgumentException("Code required");
        // ... validation
        return new Country { Id = Guid.NewGuid(), Code = code, Name = name };
    }
}

// Application Layer - Unnecessary for CRUD
public interface ICountryRepository
{
    Task<Country?> GetByIdAsync(Guid id);
    Task<IReadOnlyList<Country>> GetAllAsync();
    Task AddAsync(Country country);
}

public class CountryService
{
    private readonly ICountryRepository _repository;
    
    public async Task<List<CountryDto>> GetAllCountriesAsync()
    {
        var countries = await _repository.GetAllAsync();
        return countries.Select(c => new CountryDto
        {
            Id = c.Id,
            Code = c.Code,
            Name = c.Name
        }).ToList();
    }
}

// Presentation Layer
[ApiController]
[Route("api/[controller]")]
public class CountriesController : ControllerBase
{
    private readonly CountryService _service;
    
    [HttpGet]
    public async Task<ActionResult<List<CountryDto>>> GetAll()
    {
        return Ok(await _service.GetAllCountriesAsync());
    }
}

// Result: 5+ files for a simple lookup table!
// โœ… Simple approach for simple data

// 1. Use a simple model (not a complex entity)
public class Country
{
    public int Id { get; set; }
    public string Code { get; set; }
    public string Name { get; set; }
}

// 2. Direct query in controller (for simple read-only data)
[ApiController]
[Route("api/[controller]")]
public class CountriesController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    
    [HttpGet]
    [ResponseCache(Duration = 3600)] // Cache for 1 hour
    public async Task<ActionResult<List<Country>>> GetAll()
    {
        // โœ… Direct query for simple read-only data
        return await _context.Countries
            .OrderBy(c => c.Name)
            .ToListAsync();
    }
}

// Total: 1 file. Simple. Clear. Maintainable.

When to Keep It Simple:

  • โœ… Lookup tables (countries, states, categories)
  • โœ… Read-only data
  • โœ… No business rules
  • โœ… No complex validation
  • โœ… Simple CRUD operations

When to Use Full Architecture:

  • โœ… Complex business logic
  • โœ… Multiple validation rules
  • โœ… Domain events needed
  • โœ… Complex aggregates
  • โœ… Frequent changes expected

PracticeBenefit
โœ… Keep domain pure (no infrastructure)Testable, flexible
โœ… Use rich domain models with behaviorEncapsulation, less bugs
โœ… Create specific repository methodsClear intent, optimized
โœ… Use DTOs for API contractsDecoupling, security
โœ… Implement value objectsType safety, validation
โœ… Keep controllers thinTestability, reusability
โœ… Use factory methodsEnforce invariants
โœ… Apply patterns pragmaticallyRight tool for the job
Anti-patternProblem
โŒ Reference outer layers from innerBreaks dependency rule
โŒ Create anemic domain modelsBusiness logic scattered
โŒ Expose IQueryable from repositoriesLeaky abstraction
โŒ Return entities from APITight coupling, security
โŒ Use primitives for domain conceptsValidation duplication
โŒ Put business logic in controllersHard to test, duplicate code
โŒ Over-engineer simple featuresUnnecessary complexity
โŒ Generic repository for everythingOne size doesn’t fit all

Use this checklist to evaluate your implementation:

  • Zero dependencies on outer layers
  • No reference to Entity Framework, ASP.NET, etc.
  • Entities have private setters
  • Factory methods for object creation
  • Business rules enforced in domain
  • Value objects for domain concepts
  • Domain events for important changes
  • Defines repository interfaces
  • Contains use case logic
  • Uses DTOs for data transfer
  • No direct database access
  • Thin orchestration services
  • Clear command/query separation
  • Implements interfaces from Application
  • Contains EF Core configurations
  • Repository implementations optimized
  • No business logic here
  • Controllers are thin (< 30 lines per action)
  • Only HTTP concerns handled
  • Exception handling to HTTP status codes
  • No database access
  • No business logic

If you identified issues in your codebase, follow this refactoring plan:

  1. Add private setters to domain entities
  2. Create DTOs for API responses
  3. Move validation into domain entities
  4. Extract factory methods for entity creation
  1. Remove IQueryable from repository interfaces
  2. Create specific methods for common queries
  3. Encapsulate complex queries in repositories
  4. Add Unit of Work for transaction management
  1. Move business logic from services to entities
  2. Create value objects for domain concepts
  3. Implement domain events
  4. Add domain factory methods
  1. Remove infrastructure dependencies from domain
  2. Create clear interfaces in Application layer
  3. Refactor controllers to be thin
  4. Implement proper layering

Clean Architecture is powerful but easy to misuse. The most common mistakes are:

  1. Breaking the Dependency Rule - Keep domain pure
  2. Anemic Domain Models - Put behavior with data
  3. Exposing Entities - Use DTOs for external contracts
  4. Ignoring Value Objects - Encapsulate domain concepts
  5. Repository Anti-patterns - Be specific, hide implementation
  6. Fat Controllers - Keep business logic in domain/services
  7. Over-engineering - Apply patterns pragmatically

Remember: Clean Architecture is about separating concerns and protecting your business logic. When in doubt, ask yourself:

“Can I test this business rule without any infrastructure?”

If the answer is no, you might be making one of these mistakes.

The best architecture is the simplest one that solves your problem. Start simple, add complexity only when needed.



Have you made any of these mistakes? Share your experiences in the comments below! ๐Ÿ’ฌ

Found this helpful? Share it with your team! ๐Ÿš€