Clean Architecture
Put the business rules in the center of the app, and keep technical details like the database and the web outside. All dependencies point only toward the center. With this, the important logic of the app is tested without a database, and changing tools does not hurt it.
Author: bezzad
The problem: business logic everywhere
Our online shop has been running for three years. Where in the code is the rule “the order total is at most 20 million toman”? A new developer searches and finds this:
- Once in the order controller. It is checked before saving.
- Once in a Stored Procedure. Someone added it last year.
- Once in a class that works with EF Core. With a slightly different number.
Now we have several problems:
- Testing the rule without the database and the web is not possible. The rule is stuck to the controller and the DbContext.
- Changing a tool changes the logic. If we move from EF Core to Dapper, the rules must move too.
- Nobody knows the right place. Each person writes a new rule wherever it is easier.
Clean Architecture gives a simple answer to this question: where should each thing be, and what should it depend on?
The idea: the onion and the dependency rule
See the app as an onion with several rings. The most important and most stable code is in the center. Details that change often are outside.
One main rule holds everything together. We call it the Dependency Rule:
Why is this rule useful?
- The center is stable. The “20 million limit” rule does not change for years, but the EF Core version changes every year.
- Outer changes do not reach the inside. When the database changes, only the outer ring changes.
- Testing becomes simple. You test the domain and the application without a database and without a web server.
Four layers, with an example
| Layer | What is inside it? | In our shop |
|---|---|---|
| Domain | Entities, Value Objects and business rules | The Order class and the 20 million limit rule |
| Application | The tasks the user does, and the interfaces it needs | Placing an order, and the IOrderRepository interface |
| Infrastructure | Implementations of the interfaces with real tools | The EfOrderRepository class with EF Core, sending email |
| Presentation | The input and output of the app | Minimal API endpoints |
The domain layer is where the ideas of the DDD lesson live: Entity, Value Object and Aggregate. Clean Architecture says where this code should be. DDD says how this code should be written. These two work well together, but each one can also be used without the other.
How does the domain not need the database?
The important question is this: the application layer must save the order. But it is not allowed to know EF Core. So how?
The answer is the same dependency inversion from the SOLID lesson:
- The application layer defines an interface. This interface says “I need something that loads and saves an order”.
- The infrastructure layer implements it. The EfOrderRepository class works with EF Core.
- The presentation layer connects these two in DI. Only this layer knows both of them.
Here is an interesting point: the run path and the dependency direction are different. At run time, the request goes from the API to the database. But in the code, the infrastructure depends on the application interface.
Code
Project structure
Each layer is a separate project. The references enforce the dependency rule with the compiler:
<!-- Shop.Application.csproj -->
<ItemGroup>
<ProjectReference Include="..\Shop.Domain\Shop.Domain.csproj" />
</ItemGroup>
<!-- Shop.Infrastructure.csproj -->
<ItemGroup>
<ProjectReference Include="..\Shop.Application\Shop.Application.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
</ItemGroup>
The Shop.Domain project has no references. Not to EF Core, and not to any other project.
Domain
namespace Shop.Domain;
public sealed class Order
{
private const decimal MaxTotal = 20_000_000;
private readonly List<OrderLine> _lines = [];
public Guid Id { get; } = Guid.NewGuid();
public IReadOnlyList<OrderLine> Lines => _lines;
public decimal Total => _lines.Sum(line => line.Price * line.Quantity);
public void AddLine(Guid productId, decimal price, int quantity)
{
if (Total + price * quantity > MaxTotal)
throw new DomainException("Order total is over the limit.");
_lines.Add(new OrderLine(productId, price, quantity));
}
}
Application
namespace Shop.Application.Orders;
public interface IOrderRepository
{
void Add(Order order);
Task SaveChangesAsync(CancellationToken ct);
}
public sealed record PlaceOrderCommand(IReadOnlyList<CartItem> Items);
public sealed class PlaceOrderHandler(IOrderRepository orders)
{
public async Task<Guid> HandleAsync(PlaceOrderCommand command, CancellationToken ct)
{
var order = new Order();
foreach (var item in command.Items)
order.AddLine(item.ProductId, item.Price, item.Quantity);
orders.Add(order);
await orders.SaveChangesAsync(ct);
return order.Id;
}
}
The application layer does not check any rule itself. It only coordinates the work: create, hand it to the domain, save.
Infrastructure
namespace Shop.Infrastructure;
internal sealed class EfOrderRepository(ShopDbContext db) : IOrderRepository
{
public void Add(Order order) => db.Orders.Add(order);
public Task SaveChangesAsync(CancellationToken ct) => db.SaveChangesAsync(ct);
}
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services, string connectionString)
{
services.AddDbContext<ShopDbContext>(options => options.UseSqlServer(connectionString));
services.AddScoped<IOrderRepository, EfOrderRepository>();
return services;
}
}
The EfOrderRepository class is internal. No other project can use it directly.
Presentation
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<PlaceOrderHandler>();
builder.Services.AddInfrastructure(builder.Configuration.GetConnectionString("Shop")!);
var app = builder.Build();
app.MapPost("/orders", async (PlaceOrderCommand command, PlaceOrderHandler handler, CancellationToken ct) =>
{
var id = await handler.HandleAsync(command, ct);
return TypedResults.Created($"/orders/{id}", id);
});
app.Run();
Important rules
- The domain has no references. No EF Core, no ASP.NET Core, no infrastructure library.
- The interface belongs to the one who uses it. The repository interface is in the application, not in the infrastructure.
- Business rules are in the domain. Not in the controller, not in the Handler, not in the database.
- Only the presentation layer knows everything. This layer is where everything is connected together in DI.
- Check the boundary with tools. Project references catch most mistakes. For more exact rules, write architecture tests with libraries like NetArchTest or ArchUnitNET.
Common mistakes
| Mistake | Why is it bad? | The right way |
|---|---|---|
| EF Core attributes on domain classes | The domain depends on database details. | Set up mapping with configuration classes in the infrastructure. |
| Business rules in the controller or Handler | The domain becomes anemic, and the rules are repeated. | The rule inside a method of the Entity itself. |
| The repository interface next to the implementation, in the infrastructure | The dependency direction is not inverted. | The interface in the application layer. |
| One generic Repository for all tables on top of EF Core | An extra layer that hides the EF Core features. | Either a repository for each Aggregate, or the DbContext itself behind an interface. |
| DTOs and Mappers between every two layers | Five files change for every new field. | Make separate models only at real boundaries, like API input and output. |
| Four projects for a small CRUD service | A heavy structure, with no business logic worth protecting. | One simple project, or folders by feature (Vertical Slice). |
When to use Clean Architecture?
Good fit
- There is a lot of important business logic.
- The system lives for years, and the tools change during that time.
- You want to test the rules quickly and without a database.
- Several teams or several developers work on one service.
Poor fit
- A small service that mostly reads data and returns it.
- A prototype or a short-term project.
- The team is small, and the layers only slow the work down.
Summary in six lines
- Business logic is in the center. Technical details like the database and the web are outside.
- Dependencies point only toward the center. The domain knows nothing.
- The application layer defines interfaces. The infrastructure implements them.
- The run path goes outward, but the code dependency points inward.
- Project references and architecture tests check the dependency rule automatically.
- For a small CRUD service, this structure is heavy. Start simple.