Common mistakes when implementing Clean Architecture in .NET
Clean Architecture done wrong delivers MORE complexity than a plain controller with no benefits. Here are the eight mistakes I see most in production .NET codebases.
1. Anaemic domain model
The most common mistake. Entities are bags of public setters; all logic lives in services.
// ❌ Anaemic
public class Order {
public Guid Id { get; set; }
public OrderStatus Status { get; set; }
}
public class OrderService {
public void Cancel(Order order) {
// business rule lives here, easily bypassed
if (order.Status == OrderStatus.Shipped) throw ...;
order.Status = OrderStatus.Cancelled;
}
}
Anyone with a reference to order can do order.Status = OrderStatus.Cancelled and skip the rule.
Fix: make setters private + put the rule on the entity.
public class Order {
public OrderStatus Status { get; private set; }
public void Cancel(string reason, DateTimeOffset now) {
if (Status == OrderStatus.Shipped)
throw new DomainException("Cannot cancel shipped");
Status = OrderStatus.Cancelled;
}
}
2. EF Core attributes on domain entities
[Table("orders")]
public class Order {
[Key]
public Guid Id { get; set; }
[Required]
public virtual Customer Customer { get; set; }
}
Domain is now coupled to EF Core. Moving to a different ORM means editing every entity.
Fix: all EF configuration in OnModelCreating via the fluent API, in the Infrastructure/Persistence folder. Domain stays pristine.
3. Repository interfaces in the wrong layer
Where does IOrderRepository belong? Beginners put it in Infrastructure (with the implementation) or in Domain. Both are wrong.
Correct: Application defines the interface — the use case decides what it needs.
Application/UseCases/PlaceOrder/
├── PlaceOrderCommand.cs
├── PlaceOrderHandler.cs
└── IOrderRepository.cs ← here
Infrastructure/Persistence/
└── EfOrderRepository.cs ← implements it
4. Repositories exposing IQueryable
public interface IOrderRepository {
IQueryable<Order> Query(); // ❌
}
Callers can now chain .Where(...).Include(...) — Application is coupled to LINQ-to-EF. If you migrate to Mongo, half the call sites break.
Fix: return concrete collections. Add specific methods named after intent:
public interface IOrderRepository {
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct);
Task<IReadOnlyList<Order>> GetRecentForCustomerAsync(Guid customerId, int limit, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
}
If you need flexible queries, that's CQRS territory — use a separate query service that lives in Infrastructure.
5. Generic repositories
public interface IRepository<T> where T : class {
Task<T?> GetAsync(Guid id);
Task AddAsync(T entity);
Task RemoveAsync(T entity);
}
Every entity gets the same operations regardless of business meaning. Defeats the point of modeling repositories around aggregates.
Fix: specific repository per aggregate root, with operations named for business intent.
6. Use cases doing IO directly
public class PlaceOrderHandler {
public async Task Handle(PlaceOrderCommand cmd) {
var now = DateTime.UtcNow; // ❌ direct IO
var order = new Order { CreatedAt = now };
await File.AppendAllTextAsync("audit.log", ...); // ❌
await new SmtpClient().SendAsync(...); // ❌
}
}
Direct IO makes the handler untestable.
Fix: inject abstractions for everything that touches the outside world:
public class PlaceOrderHandler {
public PlaceOrderHandler(IClock clock, IAuditLog audit, IEmailSender email) { ... }
}
IClock is the simplest. Wrap DateTimeOffset.UtcNow so tests can pin time.
7. Mapping for the sake of mapping
The over-engineered version has FOUR mapping layers:
Domain.Order → Application.OrderDto → Persistence.OrderEntity → Web.OrderResponse
Four classes that are almost identical. Four AutoMapper profiles. Hundreds of lines of mapping code for no gain.
Fix: keep it to TWO at most:
Domain.Order(entity) — used for writesWeb.OrderResponse(DTO) — shaped for the UI
Use EF's fluent API to map Order directly to the table. Project to the response DTO in the query handler.
8. Composition root sprawl
Program.cs grows to 500 lines of DI registrations + middleware.
Fix: split into extension methods per layer:
// Application/ApplicationServices.cs
public static IServiceCollection AddApplication(this IServiceCollection services) {
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(PlaceOrderHandler).Assembly));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
return services;
}
// Infrastructure/InfrastructureServices.cs
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config) {
services.AddDbContext<AppDbContext>(o => o.UseNpgsql(config.GetConnectionString("Db")!));
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddSingleton<IClock, SystemClock>();
return services;
}
// Web/Program.cs
builder.Services
.AddApplication()
.AddInfrastructure(builder.Configuration)
.AddControllers();
Program.cs stays under 30 lines. Each layer owns its own wiring.
Bonus pitfall — Adopting Clean Architecture for a CRUD app
If your app's entire logic is "store this JSON, return it later", Clean Architecture is overhead. You'll have:
- 4 projects to maintain
- 4 files per endpoint
- A "domain" that's just data classes with no behavior
- Tests that mock nothing meaningful because nothing meaningful is happening
Fix: use a single ASP.NET Core project with Minimal APIs + EF Core. Refactor toward Clean Architecture WHEN you start writing real business rules.
Signal-of-overengineering checklist
- 80% of your "use cases" are 3-line
Add + SaveChangesoperations - Most entities have no methods, just properties
- No test for a business rule has ever caught a real bug
- You spend more time mapping between layers than writing features
- Single developer maintaining the project
If 3+ of these are true, Clean Architecture is hurting more than helping. Simplify.
Interview-grade summary
"Clean Architecture done right is liberating. Done wrong, it's a tax. The most common mistakes are anaemic domain models (logic in services, not entities), EF attributes leaking into Domain, repository interfaces in the wrong layer, generic repositories, use cases doing IO directly, over-mapping between layers, and adopting it for trivial CRUD apps. Get those right and the architecture pays for itself. Get them wrong and you have 4 projects of complexity for no benefit."