Clean Architecture in ASP.NET Core: Why I Stopped Fighting It

Search for a command to run...

No comments yet. Be the first to comment.
The hardest incidents I've dealt with weren't the ones with obvious causes. They were the ones where a request slowed down somewhere across four or five services, and nobody could say exactly where, b
Entity Framework Core makes it easy to get an application working. It does not automatically make that application fast. I've spent a good chunk of my career optimizing systems where the biggest perfo
Distributed systems fail in ways single applications never do. A service that works perfectly in isolation can still bring down an entire platform the moment a downstream dependency slows down or drop

When Minimal APIs landed in .NET 6, a lot of engineers treated them as a toy. Fine for a demo, fine for a quick prototype, but not something you'd trust in a real production system. I understood the s
Early in my career, I built systems the way most of us do when we're starting out: controllers talking directly to the database, business logic scattered across services, view models doubling as domain models. It works, until the codebase grows past a certain size and a certain number of hands touching it. I hit that wall while working on a loan management portal that had to handle application, verification, and disbursement processes for thousands of users, and again on an insurance platform where the business rules kept changing faster than the code could comfortably absorb them.
Clean Architecture, applied without dogma, solved a real problem for me: it made change safe.
The core idea is a dependency direction, not a specific folder structure. Your domain layer, the entities and business rules that define what your system actually does, should not know or care about ASP.NET Core, Entity Framework, or any external framework. Everything else depends inward on the domain. The domain depends on nothing.
Domain → entities, business rules, no external dependencies
Application → use cases, orchestration, depends only on Domain
Infrastructure → EF Core, external APIs, depends on Application/Domain
API → controllers, depends on Application
The payoff isn't philosophical. It's that you can change your database provider, swap a third-party integration, or rewrite your API layer without touching the rules that actually define your business.
I've seen Clean Architecture implemented in name only, where the layers exist as folders but the dependency direction is violated constantly. Entities with EF Core attributes scattered through them. Domain logic that calls out to infrastructure services directly. The structure looks right in a diagram and does nothing for you in practice.
The dependency direction is the entire point. If your domain project references Entity Framework, you don't have Clean Architecture, you have a differently organized monolith.
Instead of a bloated OrderService with fifteen methods, I define a use case per operation:
public class ApproveLoanApplication
{
private readonly ILoanRepository _repository;
public ApproveLoanApplication(ILoanRepository repository)
{
_repository = repository;
}
public async Task<Result> ExecuteAsync(ApproveLoanCommand command)
{
var application = await _repository.GetByIdAsync(command.ApplicationId);
if (application is null) return Result.Failure("Application not found");
application.Approve(command.ApprovedBy);
await _repository.SaveAsync(application);
return Result.Success();
}
}
This makes the system's actual capabilities readable from the folder structure alone. Anyone can open the Application layer and see, in plain terms, what the system does, without wading through a generic service class doing five unrelated things.
A subtlety that trips people up: the repository interface lives in the Application or Domain layer, not Infrastructure. Infrastructure implements it. This is what actually enables swapping implementations later, whether that's moving from SQL Server to PostgreSQL or introducing a caching layer in front of a repository, without the core business logic needing to change at all.
// In Application layer
public interface ILoanRepository
{
Task<LoanApplication> GetByIdAsync(Guid id);
Task SaveAsync(LoanApplication application);
}
// In Infrastructure layer
public class LoanRepository : ILoanRepository
{
private readonly AppDbContext _context;
// implementation using EF Core
}
Clean Architecture can be taken further than it needs to be. I don't wrap every single database call in a use case if a simple CRUD endpoint genuinely is just CRUD. I don't introduce a mediator pattern for every operation if the team isn't already comfortable with it. The goal is maintainability and testability, not architectural purity for its own sake.
Where I insist on the discipline is anywhere business rules live: loan approval logic, fraud detection thresholds, pricing calculations, anything where a bug has real financial consequences. That code needs to be isolated, unit tested without spinning up a database, and protected from accidental coupling to infrastructure concerns.
This is the payoff that convinced me to stick with the pattern. Because use cases depend on interfaces rather than concrete infrastructure, testing business logic doesn't require a database, an HTTP call, or any external dependency:
[Fact]
public async Task ApproveLoanApplication_SetsStatusToApproved()
{
var mockRepo = new Mock<ILoanRepository>();
mockRepo.Setup(r => r.GetByIdAsync(applicationId)).ReturnsAsync(pendingApplication);
var useCase = new ApproveLoanApplication(mockRepo.Object);
var result = await useCase.ExecuteAsync(new ApproveLoanCommand(applicationId, approverId));
Assert.True(result.IsSuccess);
Assert.Equal(LoanStatus.Approved, pendingApplication.Status);
}
Fast, isolated tests that run in milliseconds and actually get run regularly, instead of a thin layer of integration tests everyone's afraid to touch.
Clean Architecture isn't about following a diagram correctly. It's about being able to onboard a new developer onto a large codebase and have them understand what the system does without archaeology, and being able to change infrastructure decisions later without rewriting the rules that actually matter. On systems handling real financial and legal processes, that kind of safety net isn't optional. It's what lets the codebase survive years of changing requirements without collapsing under its own complexity.