Clean Architecture is not a folder renaming exercise
Teams hear "Clean Architecture," draw concentric circles, create seven projects, and still inject DbContext into controllers. Six months later, nobody knows where a business rule lives. Clean Architecture by Robert Martin is about dependency direction: domain rules do not depend on frameworks, UI, or databases. On .NET, that means your entities and use cases compile without referencing EF Core, ASP.NET, or Redis. Toolliyo teaches this with working steps because interviewers ask you to defend boundaries on code you shipped—not diagrams alone.
Start with the dependency rule
Source code dependencies point inward. Outer rings orchestrate inner rings through interfaces defined inward. The domain knows nothing about SQL. Infrastructure implements interfaces the application defines. This is the same inversion as ports and adapters, hexagonal, onion—learn one deeply.
Solution structure that scales without ceremony
A pragmatic layout for many enterprise APIs:
- Domain — entities, value objects, domain events, domain exceptions.
- Application — use cases (commands/queries), DTOs, validators, interfaces for repositories and clocks.
- Infrastructure — EF Core, email, file storage, external HTTP clients.
- Web — ASP.NET Core host, controllers or minimal APIs, filters, auth wiring.
You do not need twelve projects on day one. A four-project solution beats a monolith where Program.cs registers everything globally without tests.
Step 1: Extract domain concepts from controllers
Pick one feature—create order, approve leave request. List business rules currently buried in controllers or services static classes. Move invariants into domain methods:
public class Order {
private readonly List<OrderLine> _lines = new();
public void AddLine(Product product, int qty) {
if (qty <= 0) throw new DomainException("Quantity must be positive");
_lines.Add(new OrderLine(product, qty));
}
public Money Total => _lines.Aggregate(Money.Zero, (t, l) => t + l.LineTotal);
}
Controllers should not calculate totals; they call application handlers.
Step 2: Application layer use cases with MediatR or plain services
Each use case is one class with a request and response. Inject interfaces, not DbContext:
public record CreateOrderCommand(Guid CustomerId, IReadOnlyList<LineDto> Lines) : IRequest<Guid>;
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid> {
private readonly IOrderRepository _orders;
private readonly IUnitOfWork _uow;
// constructor...
public async Task<Guid> Handle(CreateOrderCommand cmd, CancellationToken ct) {
var order = Order.Create(cmd.CustomerId);
foreach (var line in cmd.Lines) order.AddLine(/* map */);
await _orders.AddAsync(order, ct);
await _uow.SaveChangesAsync(ct);
return order.Id;
}
}
MediatR is optional; a simple ICreateOrderService interface works. Avoid turning handlers into god classes—split by use case.
Step 3: Define repository interfaces in Application
IOrderRepository lives in Application or Domain depending on your style; implementation lives in Infrastructure with EF Core. Return domain types where possible; mapping at the persistence boundary keeps EF attributes out of domain if you prefer purity—many teams use private EF configurations and accept pragmatic coupling on aggregates.
Step 4: Infrastructure implements persistence
DbContext, configurations, migrations stay here. Use specifications or focused query methods to avoid leaking IQueryable to Application. For reads, CQRS-style query handlers with Dapper or projected EF queries are fine.
Step 5: Web layer stays thin
Controllers map HTTP to commands, return ProblemDetails on failures, enforce auth. No business branching beyond authorization concerns. Validation: FluentValidation in Application pipeline or domain guards for true invariants.
Testing strategy that pays for the refactor
Domain tests: fast, no database. Application tests: fake repositories, verify handler behavior. Integration tests: WebApplicationFactory with Testcontainers SQL for a few golden paths. This pyramid makes refactors safe when product changes rules weekly.
Migrating a legacy monolith without stopping the world
- Strangle one vertical slice (feature) into new projects while old code remains.
- Introduce anti-corruption layer if external systems use messy DTOs.
- Stop adding new logic to old fat controllers—only touch new handlers.
- Align team on naming and where tests live before mandating MediatR everywhere.
When Clean Architecture is overkill
Prototypes, hackathons, and two-week MVPs can be a single project. Honest architects scale structure with team size and product lifespan. If the app will live five years with multiple teams, invest early. If it is a throwaway integration, a well-factored modular monolith in one assembly may suffice.
Common failures we see in reviews
- Anemic domain—entities are bags of properties; all logic in handlers.
- Application referencing Infrastructure "just once" for a helper—dependency rule broken.
- Generic repository abstraction over every table—adds no value.
- Mapping explosion without AutoMapper profiles tested—bugs in production.
Interview narrative
Describe a slice you migrated: before/after dependency diagram, test count increase, deploy risk reduction. Mention trade-off: more files, clearer ownership. Seniors admit costs, not only benefits.
Clean Architecture on .NET succeeds when you move rules inward incrementally, test use cases, and keep infrastructure replaceable. Start one feature, prove velocity, then expand—the goal is maintainable products, not perfect circles on a slide.