Clean Architecture · How it works
1 min read- How it works
- The Dependency Rule and Layer Boundaries
- The Concentric Circles
- How the Rule Works in Practice
- Real-World Example with ASP.NET Core
- Domain
- Application -- Command, Validator, Handler
- Application -- Domain Event Handler
- Infrastructure -- EF Configuration
- Presentation -- Controller
- Presentation -- Global Exception Handling Middleware
How it works
This guide covers Clean Architecture in depth for interview preparation, with practical C# and ASP.NET Core examples throughout.
The Dependency Rule and Layer Boundaries
The single most important rule of Clean Architecture is the Dependency Rule:
Source code dependencies must point only inward, toward higher-level policies.
Nothing in an inner circle can know anything about something in an outer circle. This includes concrete classes, functions, variable names, or any software entity declared in an outer circle.
The Concentric Circles
+-----------------------------------------------------------+
| Frameworks & Drivers |
| +---------------------------------------------------+ |
| | Interface Adapters | |
| | +-------------------------------------------+ | |
| | | Application | | |
| | | +-----------------------------------+ | | |
| | | | Entities | | | |
| | | | (Domain Core) | | | |
| | | +-----------------------------------+ | | |
| | +-------------------------------------------+ | |
| +---------------------------------------------------+ |
+-----------------------------------------------------------+
How the Rule Works in Practice
- Domain has zero outward dependencies. It defines interfaces that outer layers implement.
- Application depends on Domain. It defines use-case interfaces (ports) that Infrastructure implements.
- Infrastructure depends on Application and Domain. It implements the interfaces.
- Presentation depends on Application (and transitively Domain). It dispatches commands/queries.
Crossing a boundary always requires Dependency Inversion: the inner layer declares an abstraction, the outer layer provides the implementation, and a DI container wires them at startup.
// Domain layer -- declares the contract
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
// Infrastructure layer -- provides the implementation
public sealed class EfOrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public EfOrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct)
=> await _db.Orders.FindAsync(new object[] { id }, ct);
public async Task AddAsync(Order order, CancellationToken ct)
=> await _db.Orders.AddAsync(order, ct);
public async Task SaveChangesAsync(CancellationToken ct)
=> await _db.SaveChangesAsync(ct);
}
The Application layer uses IOrderRepository without ever knowing about EF Core.
Real-World Example with ASP.NET Core
Below is a complete vertical slice showing how a "Close Trade" feature flows through every layer.
Domain
// Trade.cs (entity method shown earlier)
public void Close(decimal exitPrice)
{
if (Status != TradeStatus.Open)
throw new DomainException("Only open trades can be closed.");
Status = TradeStatus.Closed;
ClosedAtUtc = DateTime.UtcNow;
_domainEvents.Add(new TradeClosedEvent(Id, exitPrice));
}
Application -- Command, Validator, Handler
// CloseTradeCommand.cs
public sealed record CloseTradeCommand(Guid TradeId, decimal ExitPrice) : IRequest<Unit>;
// CloseTradeValidator.cs
public sealed class CloseTradeValidator : AbstractValidator<CloseTradeCommand>
{
public CloseTradeValidator()
{
RuleFor(x => x.TradeId).NotEmpty();
RuleFor(x => x.ExitPrice).GreaterThan(0);
}
}
// CloseTradeHandler.cs
public sealed class CloseTradeHandler : IRequestHandler<CloseTradeCommand, Unit>
{
private readonly ITradeRepository _trades;
private readonly IUnitOfWork _unitOfWork;
public CloseTradeHandler(ITradeRepository trades, IUnitOfWork unitOfWork)
{
_trades = trades;
_unitOfWork = unitOfWork;
}
public async Task<Unit> Handle(CloseTradeCommand request, CancellationToken ct)
{
var trade = await _trades.GetByIdAsync(request.TradeId, ct)
?? throw new NotFoundException(nameof(Trade), request.TradeId);
trade.Close(request.ExitPrice);
await _unitOfWork.CommitAsync(ct);
return Unit.Value;
}
}
Application -- Domain Event Handler
// TradeClosedEventHandler.cs
public sealed class TradeClosedEventHandler : INotificationHandler<TradeClosedEvent>
{
private readonly INotificationService _notifications;
private readonly ILogger<TradeClosedEventHandler> _logger;
public TradeClosedEventHandler(
INotificationService notifications,
ILogger<TradeClosedEventHandler> logger)
{
_notifications = notifications;
_logger = logger;
}
public async Task Handle(TradeClosedEvent notification, CancellationToken ct)
{
_logger.LogInformation("Trade {TradeId} closed at {ExitPrice}",
notification.TradeId, notification.ExitPrice);
await _notifications.SendAsync(
$"Trade {notification.TradeId} closed at {notification.ExitPrice}", ct);
}
}
Infrastructure -- EF Configuration
// TradeConfiguration.cs
public sealed class TradeConfiguration : IEntityTypeConfiguration<Trade>
{
public void Configure(EntityTypeBuilder<Trade> builder)
{
builder.ToTable("Trades");
builder.HasKey(t => t.Id);
builder.Property(t => t.Symbol)
.HasMaxLength(10)
.IsRequired();
builder.Property(t => t.Quantity)
.HasPrecision(18, 8);
builder.Property(t => t.EntryPrice)
.HasPrecision(18, 8);
builder.Property(t => t.Status)
.HasConversion<string>()
.HasMaxLength(20);
// Ignore domain events -- they are not persisted
builder.Ignore(t => t.DomainEvents);
}
}
Presentation -- Controller
[ApiController]
[Route("api/trades")]
public sealed class TradesController : ControllerBase
{
private readonly IMediator _mediator;
public TradesController(IMediator mediator) => _mediator = mediator;
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> OpenTrade(
[FromBody] OpenTradeCommand command,
CancellationToken ct)
{
var tradeId = await _mediator.Send(command, ct);
return Ok(new { tradeId });
}
[HttpPut("{id:guid}/close")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> CloseTrade(
Guid id,
[FromBody] CloseTradeRequest request,
CancellationToken ct)
{
await _mediator.Send(new CloseTradeCommand(id, request.ExitPrice), ct);
return NoContent();
}
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(TradeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var trade = await _mediator.Send(new GetTradeByIdQuery(id), ct);
return trade is null ? NotFound() : Ok(trade);
}
}
public sealed record CloseTradeRequest(decimal ExitPrice);
Presentation -- Global Exception Handling Middleware
public sealed class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (ValidationException ex)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new
{
errors = ex.Errors.Select(e => e.ErrorMessage)
});
}
catch (NotFoundException ex)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(new { error = ex.Message });
}
catch (DomainException ex)
{
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
await context.Response.WriteAsJsonAsync(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { error = "An unexpected error occurred." });
}
}
}