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:
๐ฏ Introduction: Why Developers Struggle
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.
๐ซ Mistake #1: Breaking the Dependency Rule
The Problem
The most fundamental rule of Clean Architecture is: Dependencies point inward. Inner layers should NEVER depend on outer layers.
โ Bad Example: Domain Depending on Infrastructure
// โ 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 Approach: Pure Domain with Repository Abstraction
// โ
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
๐ซ Mistake #2: Anemic Domain Model
The Problem
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.
โ Bad Example: All Logic in Services
// โ 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
โ Correct Approach: Rich Domain Model
// โ
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
๐ซ Mistake #3: Exposing Entities Directly Through API
The Problem
Returning domain entities directly from controllers creates tight coupling between internal domain structure and external API contracts.
โ Bad Example: Exposing Domain Entities
// โ 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
PasswordHashand 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
โ Correct Approach: Use DTOs
// โ
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
๐ซ Mistake #4: Ignoring Value Objects
The Problem
Using primitives for domain concepts that have validation rules and behavior leads to scattered validation and potential bugs.
โ Bad Example: Primitive Obsession
// โ 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
โ Correct Approach: Use Value Objects
// โ
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
Moneyeverywhere in domain - โ
Expressiveness -
Moneyis more meaningful thandecimal
๐ซ Mistake #5: Repository Anti-patterns
The Problem
Repositories that are too generic or expose IQueryable leak persistence concerns into the application layer.
โ Bad Example: Generic Repository with IQueryable
// โ 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
โ Correct Approach: Specific Repository Methods
// โ
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
๐ซ Mistake #6: Putting Business Logic in Controllers
The Problem
Controllers should only handle HTTP concerns. Business logic in controllers makes testing difficult and leads to code duplication.
โ Bad Example: Fat Controllers
// โ 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
โ Correct Approach: Thin Controllers
// โ
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
๐ซ Mistake #7: Over-Engineering Simple Features
The Problem
Applying Clean Architecture to every feature, no matter how simple, creates unnecessary complexity.
โ Bad Example: Over-Engineered Simple Feature
// โ 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!โ Correct Approach: Keep It Simple
// โ
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
๐ Summary: Dos and Don’ts
โ DO
| Practice | Benefit |
|---|---|
| โ Keep domain pure (no infrastructure) | Testable, flexible |
| โ Use rich domain models with behavior | Encapsulation, less bugs |
| โ Create specific repository methods | Clear intent, optimized |
| โ Use DTOs for API contracts | Decoupling, security |
| โ Implement value objects | Type safety, validation |
| โ Keep controllers thin | Testability, reusability |
| โ Use factory methods | Enforce invariants |
| โ Apply patterns pragmatically | Right tool for the job |
โ DON’T
| Anti-pattern | Problem |
|---|---|
| โ Reference outer layers from inner | Breaks dependency rule |
| โ Create anemic domain models | Business logic scattered |
| โ Expose IQueryable from repositories | Leaky abstraction |
| โ Return entities from API | Tight coupling, security |
| โ Use primitives for domain concepts | Validation duplication |
| โ Put business logic in controllers | Hard to test, duplicate code |
| โ Over-engineer simple features | Unnecessary complexity |
| โ Generic repository for everything | One size doesn’t fit all |
๐ฏ Checklist: Is Your Clean Architecture Clean?
Use this checklist to evaluate your implementation:
Domain Layer
- 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
Application Layer
- Defines repository interfaces
- Contains use case logic
- Uses DTOs for data transfer
- No direct database access
- Thin orchestration services
- Clear command/query separation
Infrastructure Layer
- Implements interfaces from Application
- Contains EF Core configurations
- Repository implementations optimized
- No business logic here
Presentation Layer
- Controllers are thin (< 30 lines per action)
- Only HTTP concerns handled
- Exception handling to HTTP status codes
- No database access
- No business logic
๐ Action Plan: Fixing Your Architecture
If you identified issues in your codebase, follow this refactoring plan:
Phase 1: Quick Wins (Week 1)
- Add private setters to domain entities
- Create DTOs for API responses
- Move validation into domain entities
- Extract factory methods for entity creation
Phase 2: Repository Refactoring (Week 2-3)
- Remove IQueryable from repository interfaces
- Create specific methods for common queries
- Encapsulate complex queries in repositories
- Add Unit of Work for transaction management
Phase 3: Rich Domain Model (Week 4-6)
- Move business logic from services to entities
- Create value objects for domain concepts
- Implement domain events
- Add domain factory methods
Phase 4: Clean Boundaries (Week 7-8)
- Remove infrastructure dependencies from domain
- Create clear interfaces in Application layer
- Refactor controllers to be thin
- Implement proper layering
๐ Conclusion
Clean Architecture is powerful but easy to misuse. The most common mistakes are:
- Breaking the Dependency Rule - Keep domain pure
- Anemic Domain Models - Put behavior with data
- Exposing Entities - Use DTOs for external contracts
- Ignoring Value Objects - Encapsulate domain concepts
- Repository Anti-patterns - Be specific, hide implementation
- Fat Controllers - Keep business logic in domain/services
- 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.
Key Principle
The best architecture is the simplest one that solves your problem. Start simple, add complexity only when needed.
๐ Further Reading
Have you made any of these mistakes? Share your experiences in the comments below! ๐ฌ
Found this helpful? Share it with your team! ๐





