Every guide shows you the folder structure. Almost none of them tell you what breaks in production, why the Dependency Rule bites you at 2 AM, or how to actually wire it all together in .NET 10 without writing mountains of boilerplate.

Why Clean Architecture still matters — and where tutorials lie
Let me be direct with you. I’ve worked on five different teams that all believed they were doing Clean Architecture. Three of them were not. They had the right folders. They had the right names. But six months in, every single one had a Services folder containing God classes that touched the database, sent emails, and validated passwords all in the same method.
The tutorials weren’t lying on purpose. They just skipped the hard parts: the why behind each layer constraint, what happens when you violate the Dependency Rule just once, and how to make it all work in a real .NET 10 project without writing an interface for every single thing.
💡Clean Architecture is not a folder structure. It’s a set of dependency rules. You can get the folders right and still break the architecture. That’s what this article is about.
Our running example throughout this piece will be an e-commerce order placement system — specific enough to be real, generic enough that every developer will recognize the problems it creates.
The real layer model, drawn honestly
Here’s the diagram every tutorial shows:

This looks clean. The problem is it doesn’t show you what lives where, or more importantly, what absolutely cannot live where. Here’s the version with real content in each ring:

Print this. Tape it to your monitor. When you’re not sure where something lives, check here first.
The Domain layer: the part everyone gets wrong
The Domain layer is supposed to contain your business rules. Not your data models. Not your DTOs. Business rules — the things that would still be true if you switched from SQL Server to MongoDB, or from REST to gRPC.
In our e-commerce system, the rule “an order cannot be placed if it has zero items” is a domain rule. The rule “we send a confirmation email within 5 seconds” is an application rule. The difference matters because one belongs in your entity and the other belongs in your handler.
Entities and Value Objects — and why you need both
Most tutorials define an Order class that's essentially a row mapper — a bunch of public properties with no behaviour. That's an anemic domain model, and it's one of the top two mistakes I see in Clean Architecture codebases.
// ❌ Anemic — this is just a database row with a class name
public class Order
{
public Guid Id { get; set; }
public List<OrderItem> Items { get; set; } = [];
public decimal Total { get; set; }
public OrderStatus Status { get; set; }
}
Now watch what happens when you make the entity actually own its behaviour:
Domain/Orders/Order.cs
// ✅ Rich domain entity — behaviour lives here
public sealed class Order
{
private readonly List<OrderItem> _items = [];
private readonly List<IDomainEvent> _events = [];
public OrderId Id { get; }
public CustomerId CustomerId { get; }
public Money Total { get; private set; }
public OrderStatus Status { get; private set; }
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
private Order() { } // EF Core constructor
public static Order Create(CustomerId customerId)
{
var order = new Order
{
Id = OrderId.New(),
CustomerId = customerId,
Status = OrderStatus.Draft
};
order._events.Add(new OrderCreatedEvent(order.Id));
return order;
}
public void AddItem(ProductId productId, Money price, int quantity)
{
// Business rule lives HERE, not in a service
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a submitted order.");
var existing = _items.FirstOrDefault(i => i.ProductId == productId);
if (existing is not null)
existing.IncreaseQuantity(quantity);
else
_items.Add(OrderItem.Create(productId, price, quantity));
RecalculateTotal();
}
public void Submit()
{
if (!_items.Any())
throw new DomainException("Order must have at least one item.");
Status = OrderStatus.Submitted;
_events.Add(new OrderSubmittedEvent(Id, CustomerId, Total));
}
private void RecalculateTotal() =>
Total = _items.Aggregate(Money.Zero, (sum, item) => sum + item.LineTotal);
}
✅ Notice: no using for Entity Framework. No HTTP. No JSON serialization. The Domain layer should compile with zero references to any framework — only the .NET BCL and your own types.
Value Objects: stop using primitives for everything
The Money type above isn't decoration. It prevents an entire class of bugs. When Total is a decimal, nothing stops you from subtracting a USD amount from a EUR amount. When it's a Money object that carries its currency, that mistake throws an exception.
Domain/Common/Money.cs
public sealed record Money(decimal Amount, string Currency)
{
public static Money Zero => new(0m, "USD");
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException(
$"Cannot add {a.Currency} to {b.Currency}");
return a with { Amount = a.Amount + b.Amount };
}
}
C# records are perfect for value objects. Structural equality comes for free.
Application layer: use cases, not services
This is where most tutorials stop being useful. They show a PlaceOrderService with ten methods. That's not Clean Architecture — that's a service layer with extra steps.
The Application layer should contain one handler per use case. One command or query. One handler class. No more, no less. In .NET, MediatR is still the standard tool for this (though you could do it without it).

Application/Orders/PlaceOrder/PlaceOrderCommandHandler.cs
public sealed record PlaceOrderCommand(
Guid CustomerId,
IReadOnlyList<OrderItemDto> Items
) : IRequest<Result<Guid>>;
internal sealed class PlaceOrderCommandHandler
: IRequestHandler<PlaceOrderCommand, Result<Guid>>
{
private readonly IOrderRepository _orders;
private readonly IProductRepository _products;
private readonly IUnitOfWork _uow;
public PlaceOrderCommandHandler(
IOrderRepository orders,
IProductRepository products,
IUnitOfWork uow)
{
_orders = orders;
_products = products;
_uow = uow;
}
public async Task<Result<Guid>> Handle(
PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(new CustomerId(cmd.CustomerId));
foreach (var item in cmd.Items)
{
var product = await _products.GetByIdAsync(
new ProductId(item.ProductId), ct);
if (product is null)
return Result.Failure<Guid>($"Product {item.ProductId} not found.");
order.AddItem(product.Id, product.Price, item.Quantity);
}
order.Submit(); // domain rules run here
await _orders.AddAsync(order, ct);
await _uow.SaveChangesAsync(ct);
return Result.Success(order.Id.Value);
}
}
⚠️ Notice the handler is internal sealed. Only MediatR should instantiate it. If code outside this assembly is calling your handler directly, something is wrong with your layer boundaries.
Infrastructure: the outermost ring does the dirty work
Infrastructure implements the interfaces declared in the Application layer. That’s its only job. If you find yourself writing business logic in an EF Core repository, stop and ask whether that logic belongs in the entity instead.
nternal sealed class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(
OrderId id, CancellationToken ct) =>
await _db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task AddAsync(Order order, CancellationToken ct) =>
await _db.Orders.AddAsync(order, ct);
}
Notice what’s missing: no business logic, no validation, no decisions. Just data access. That restraint is hard to maintain under deadline pressure, but it’s what keeps the architecture honest over time.
Dependency injection in .NET 11 without the boilerplate explosion
The tutorial version shows three lines of DI registration. The real version shows registration for twenty services, three database contexts, and four HttpClient factories, all inline in Program.cs. Nobody shows you how to keep that manageable.
The answer is extension methods on IServiceCollection, one per layer:
Startup wiring
// Infrastructure/DependencyInjection.cs
public static class InfrastructureServiceExtensions
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration config)
{
services.AddDbContext<AppDbContext>(opts =>
opts.UseNpgsql(config.GetConnectionString("Default")));
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IEmailService, SmtpEmailService>();
return services;
}
}
// Application/DependencyInjection.cs
public static class ApplicationServiceExtensions
{
public static IServiceCollection AddApplication(
this IServiceCollection services)
{
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(PlaceOrderCommand).Assembly));
services.AddValidatorsFromAssembly(typeof(PlaceOrderCommand).Assembly);
return services;
}
}
// Program.cs stays clean
builder.Services
.AddApplication()
.AddInfrastructure(builder.Configuration);
🔌 In .NET 11, you can use the new Keyed Services feature to register multiple implementations of the same interface without a custom factory. This is especially useful for things like different payment provider implementations selected at runtime.
Three mistakes I see in every “Clean Architecture” codebase
1. Leaking EF Core into the Application layer
The most common one. Someone adds IQueryable<Order> to the repository interface so they can chain LINQ in the handler. Now your Application layer has an EF Core dependency and you've broken the Dependency Rule. If you need filtering, add a specific method to the repository interface — GetPendingOrdersAsync() — and implement it in Infrastructure.
2. Putting domain logic in handlers
The handler calls order.Submit(). The business rule for what "submitted" means should live on the entity. If you find yourself writing if (order.Items.Count == 0) return Error("...") in the handler, that check belongs in Order.Submit(), not in application code. Handlers orchestrate; entities enforce rules.
3. One interface per repository method
Some teams take interface segregation to the extreme and create IGetOrderByIdRepository, IAddOrderRepository, and so on. You don't need this. A single IOrderRepository with a handful of methods is fine. Interface segregation is about not forcing consumers to implement what they don't use — it's not about one interface per operation.

Real-world: an e-commerce order system, end to end
Let’s look at the full solution structure for our order system. This is what it looks like after six months in production, not on day one of a tutorial.
#Solution structure
EcommerceApp/
├── src/
│ ├── EcommerceApp.Domain/
│ │ ├── Orders/
│ │ │ ├── Order.cs
│ │ │ ├── OrderItem.cs
│ │ │ ├── OrderId.cs
│ │ │ ├── OrderStatus.cs
│ │ │ └── Events/
│ │ │ ├── OrderCreatedEvent.cs
│ │ │ └── OrderSubmittedEvent.cs
│ │ ├── Products/
│ │ │ ├── Product.cs
│ │ │ └── ProductId.cs
│ │ └── Common/
│ │ ├── Money.cs
│ │ ├── Entity.cs
│ │ └── IDomainEvent.cs
│ │
│ ├── EcommerceApp.Application/
│ │ ├── Orders/
│ │ │ ├── PlaceOrder/
│ │ │ │ ├── PlaceOrderCommand.cs
│ │ │ │ ├── PlaceOrderCommandHandler.cs
│ │ │ │ └── PlaceOrderCommandValidator.cs
│ │ │ └── GetOrder/
│ │ │ ├── GetOrderQuery.cs
│ │ │ ├── GetOrderQueryHandler.cs
│ │ │ └── OrderDto.cs
│ │ ├── Abstractions/
│ │ │ ├── IOrderRepository.cs
│ │ │ ├── IProductRepository.cs
│ │ │ ├── IEmailService.cs
│ │ │ └── IUnitOfWork.cs
│ │ └── DependencyInjection.cs
│ │
│ ├── EcommerceApp.Infrastructure/
│ │ ├── Persistence/
│ │ │ ├── AppDbContext.cs
│ │ │ ├── OrderRepository.cs
│ │ │ ├── ProductRepository.cs
│ │ │ └── Configurations/
│ │ │ └── OrderConfiguration.cs
│ │ ├── Email/
│ │ │ └── SmtpEmailService.cs
│ │ └── DependencyInjection.cs
│ │
│ └── EcommerceApp.Api/
│ ├── Endpoints/
│ │ └── OrderEndpoints.cs
│ ├── Middleware/
│ │ └── ExceptionHandlerMiddleware.cs
│ └── Program.cs
│
└── tests/
├── Domain.UnitTests/
├── Application.UnitTests/
└── Integration.Tests/
Notice the test projects mirror the source structure. Domain unit tests are the fastest — they require zero infrastructure. Application unit tests mock the repositories. Integration tests spin up a real database (TestContainers in .NET 11 makes this remarkably easy).
The Minimal API endpoint
Api/Endpoints/OrderEndpoints.cs
public static class OrderEndpoints
{
public static IEndpointRouteBuilder MapOrderEndpoints(
this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/orders")
.WithTags("Orders")
.RequireAuthorization();
group.MapPost("/", async (
PlaceOrderRequest request,
ISender sender,
CancellationToken ct) =>
{
var cmd = new PlaceOrderCommand(
request.CustomerId,
request.Items.Select(i => new OrderItemDto(i.ProductId, i.Quantity))
.ToList());
var result = await sender.Send(cmd, ct);
return result.IsSuccess
? Results.Created($"/api/orders/{result.Value}", result.Value)
: Results.BadRequest(result.Error);
});
return app;
}
}
The endpoint does one thing: translate HTTP request to MediatR command. No business logic. No database access. No domain knowledge. Just translation and HTTP response shaping.
The goal isn’t perfect architecture on day one. It’s architecture that lets you fix mistakes on day three hundred without rewriting everything.
Wrapping up
Clean Architecture in .NET is not about the folder names. It’s about the Dependency Rule: source code dependencies point inward. Your Domain doesn’t know about Entity Framework. Your Application doesn’t know about SMTP. Your Infrastructure implements the interfaces your Application declares.
The hard parts — rich domain models, value objects, one handler per use case, keeping EF out of the application layer — are the parts every tutorial skips because they take longer to explain and longer to write. But they’re also the parts that make the difference between a codebase that’s a pleasure to work in at month twelve, and one that’s already turning into a mess.
Start with the Domain. Make it pure. Then let everything else depend on it.
Enjoyed this? Follow for more articles on .NET architecture, performance, and the stuff tutorials always skip. If you found a mistake or have a different opinion on any of this — I’d genuinely love to hear it in the comments.
