Clean Architecture w .NET: Praktyczny Przewodnik
Opanowanie Clean Architecture w .NET z C#. Poznanie zasad SOLID, separacji warstw i wzorców implementacyjnych dla aplikacji łatwych w utrzymaniu.

Clean Architecture, spopularyzowana przez Roberta C. Martina (Uncle Bob), organizuje kod w taki sposób, że logika biznesowa znajduje się w centrum aplikacji, niezależnie od frameworków i szczegółów implementacyjnych. To podejście architektoniczne zapewnia testowalność, łatwość utrzymania i skalowalność aplikacji .NET. Ten przewodnik prezentuje praktyczną implementację z wykorzystaniem ASP.NET Core.
Aplikacje, które mieszają logikę biznesową z kodem infrastrukturalnym, szybko stają się trudne w utrzymaniu. Clean Architecture wymusza ścisłą separację, dzięki której można zmieniać szczegóły techniczne bez wpływu na rdzeń biznesowy.
Fundamentalne zasady Clean Architecture
Clean Architecture opiera się na inwersji zależności: warstwy wewnętrzne nie znają warstw zewnętrznych. Domena biznesowa pozostaje odizolowana i może ewoluować niezależnie od decyzji technicznych, takich jak framework webowy czy baza danych.
┌─────────────────────────────────────────────────────────┐
│ Presentation │
│ (Controllers, Razor Pages, Blazor, API Endpoints) │
├─────────────────────────────────────────────────────────┤
│ Infrastructure │
│ (EF Core, External APIs, File System, Email) │
├─────────────────────────────────────────────────────────┤
│ Application │
│ (Use Cases, Commands, Queries, DTOs) │
├─────────────────────────────────────────────────────────┤
│ Domain │
│ (Entities, Value Objects, Domain Services) │
└─────────────────────────────────────────────────────────┘Zależności zawsze prowadzą do wewnątrz: Presentation → Infrastructure → Application → Domain. Domena nie odwołuje się do żadnego innego projektu.
Struktura projektu .NET w Clean Architecture
Organizacja projektów odzwierciedla poszczególne warstwy. Każda warstwa odpowiada osobnemu projektowi w solucji Visual Studio, co zapewnia fizyczną separację odpowiedzialności.
# terminal
# Utworzenie struktury solucji
dotnet new sln -n CleanArchitecture
# Utworzenie projektów dla każdej warstwy
dotnet new classlib -n CleanArchitecture.Domain -o src/CleanArchitecture.Domain
dotnet new classlib -n CleanArchitecture.Application -o src/CleanArchitecture.Application
dotnet new classlib -n CleanArchitecture.Infrastructure -o src/CleanArchitecture.Infrastructure
dotnet new webapi -n CleanArchitecture.Api -o src/CleanArchitecture.Api
# Dodanie projektów do solucji
dotnet sln add src/CleanArchitecture.Domain
dotnet sln add src/CleanArchitecture.Application
dotnet sln add src/CleanArchitecture.Infrastructure
dotnet sln add src/CleanArchitecture.Api
# Konfiguracja referencji między projektami
cd src/CleanArchitecture.Application
dotnet add reference ../CleanArchitecture.Domain
cd ../CleanArchitecture.Infrastructure
dotnet add reference ../CleanArchitecture.Application
cd ../CleanArchitecture.Api
dotnet add reference ../CleanArchitecture.InfrastructureTaka struktura zapewnia, że Domain pozostaje niezależny, a zależności podążają za regułą Dependency Rule Clean Architecture.
Warstwa Domain: rdzeń biznesowy
Warstwa Domain zawiera encje biznesowe, Value Objects oraz interfejsy repozytoriów. W tej warstwie nie są dozwolone żadne zewnętrzne zależności.
namespace CleanArchitecture.Domain.Entities;
// Encja z tożsamością i biznesowym cyklem życia
public class Order
{
// Unikalny identyfikator zamówienia
public Guid Id { get; private set; }
// Referencja do klienta (Value Object dla adresu e-mail)
public string CustomerEmail { get; private set; }
// Kolekcja pozycji (relacja jeden-do-wielu)
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
// Status zamówienia (enum biznesowy)
public OrderStatus Status { get; private set; }
// Wyliczona kwota całkowita
public decimal TotalAmount => _items.Sum(i => i.TotalPrice);
// Daty śledzenia
public DateTime CreatedAt { get; private set; }
public DateTime? ShippedAt { get; private set; }
// Prywatny konstruktor wymusza użycie metody factory
private Order() { }
// Metoda factory tworząca poprawne zamówienie
public static Order Create(string customerEmail)
{
// Walidacja reguł biznesowych podczas tworzenia
if (string.IsNullOrWhiteSpace(customerEmail))
throw new DomainException("Adres e-mail klienta jest wymagany.");
if (!IsValidEmail(customerEmail))
throw new DomainException("Nieprawidłowy format adresu e-mail.");
return new Order
{
Id = Guid.NewGuid(),
CustomerEmail = customerEmail,
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow
};
}
// Metoda biznesowa: dodanie pozycji
public void AddItem(Product product, int quantity)
{
// Reguła biznesowa: nie można modyfikować wysłanego zamówienia
if (Status == OrderStatus.Shipped)
throw new DomainException("Nie można modyfikować wysłanego zamówienia.");
if (quantity <= 0)
throw new DomainException("Ilość musi być dodatnia.");
// Sprawdzenie, czy produkt już istnieje
var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
if (existingItem != null)
{
existingItem.IncreaseQuantity(quantity);
}
else
{
_items.Add(OrderItem.Create(this, product, quantity));
}
}
// Metoda biznesowa: potwierdzenie zamówienia
public void Confirm()
{
if (Status != OrderStatus.Pending)
throw new DomainException("Tylko oczekujące zamówienia mogą zostać potwierdzone.");
if (!_items.Any())
throw new DomainException("Zamówienie musi zawierać co najmniej jedną pozycję.");
Status = OrderStatus.Confirmed;
}
// Metoda biznesowa: wysłanie zamówienia
public void Ship()
{
if (Status != OrderStatus.Confirmed)
throw new DomainException("Zamówienie musi zostać potwierdzone przed wysyłką.");
Status = OrderStatus.Shipped;
ShippedAt = DateTime.UtcNow;
}
private static bool IsValidEmail(string email) =>
email.Contains('@') && email.Contains('.');
}Encja Order enkapsuluje swoje reguły biznesowe i chroni stan wewnętrzny. Modyfikacje muszą przechodzić przez metody biznesowe walidujące niezmienniki.
namespace CleanArchitecture.Domain.Entities;
// Encja podrzędna z własną tożsamością
public class OrderItem
{
public Guid Id { get; private set; }
public Guid OrderId { get; private set; }
public Guid ProductId { get; private set; }
public string ProductName { get; private set; }
public decimal UnitPrice { get; private set; }
public int Quantity { get; private set; }
// Wyliczenie ceny całkowitej linii
public decimal TotalPrice => UnitPrice * Quantity;
private OrderItem() { }
// Metoda factory z walidacją
public static OrderItem Create(Order order, Product product, int quantity)
{
return new OrderItem
{
Id = Guid.NewGuid(),
OrderId = order.Id,
ProductId = product.Id,
ProductName = product.Name,
UnitPrice = product.Price,
Quantity = quantity
};
}
// Metoda zwiększająca ilość
public void IncreaseQuantity(int additionalQuantity)
{
if (additionalQuantity <= 0)
throw new DomainException("Dodatkowa ilość musi być dodatnia.");
Quantity += additionalQuantity;
}
}namespace CleanArchitecture.Domain.Enums;
// Wyliczenie statusów zamówienia
public enum OrderStatus
{
Pending = 0, // Oczekuje na potwierdzenie
Confirmed = 1, // Potwierdzone, gotowe do wysyłki
Shipped = 2, // Wysłane
Delivered = 3, // Dostarczone
Cancelled = 4 // Anulowane
}Value Objects, takie jak Money, Address czy Email, enkapsulują koncepcje biznesowe nieposiadające własnej tożsamości. Ich równość opiera się na wartościach, a nie na referencjach. Takie podejście wzmacnia ekspresyjność domeny.
Interfejsy Repository w warstwie Domain
Interfejsy repozytoriów są zdefiniowane w Domain, ale ich implementacje znajdują się w Infrastructure. Ten wzorzec respektuje zasadę inwersji zależności.
namespace CleanArchitecture.Domain.Interfaces;
// Interfejs repozytorium Order
public interface IOrderRepository
{
// Pobranie po identyfikatorze
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
// Pobranie wraz z pozycjami
Task<Order?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default);
// Pobranie po adresie e-mail klienta
Task<IEnumerable<Order>> GetByCustomerEmailAsync(
string email,
CancellationToken cancellationToken = default);
// Dodanie nowego zamówienia
Task AddAsync(Order order, CancellationToken cancellationToken = default);
// Aktualizacja istniejącego zamówienia
Task UpdateAsync(Order order, CancellationToken cancellationToken = default);
// Usunięcie zamówienia
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}namespace CleanArchitecture.Domain.Interfaces;
// Wzorzec Unit of Work do zarządzania transakcjami
public interface IUnitOfWork : IDisposable
{
// Repozytoria dostępne przez UoW
IOrderRepository Orders { get; }
IProductRepository Products { get; }
// Atomowy zapis wszystkich zmian
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
// Jawne zarządzanie transakcjami
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
Task CommitTransactionAsync(CancellationToken cancellationToken = default);
Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}Wzorzec Unit of Work koordynuje operacje na wielu repozytoriach w ramach jednej transakcji.
Gotowy na rozmowy o .NET?
Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.
Warstwa Application: orkiestracja przypadków użycia
Warstwa Application zawiera logikę aplikacyjną (use cases), DTO oraz interfejsy usług zewnętrznych. Orkiestruje interakcje między domeną a światem zewnętrznym.
using MediatR;
namespace CleanArchitecture.Application.Orders.Commands;
// Command reprezentujący intencję utworzenia zamówienia
public record CreateOrderCommand(
string CustomerEmail,
List<OrderItemDto> Items
) : IRequest<Guid>;
// DTO dla pozycji zamówienia
public record OrderItemDto(
Guid ProductId,
int Quantity
);using MediatR;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
namespace CleanArchitecture.Application.Orders.Commands;
// Handler implementujący przypadek użycia
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly IUnitOfWork _unitOfWork;
public CreateOrderCommandHandler(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<Guid> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
// Utworzenie encji Order za pomocą metody factory
var order = Order.Create(request.CustomerEmail);
// Dodanie pozycji do zamówienia
foreach (var item in request.Items)
{
// Pobranie produktu z repozytorium
var product = await _unitOfWork.Products
.GetByIdAsync(item.ProductId, cancellationToken);
if (product == null)
throw new NotFoundException($"Produkt {item.ProductId} nie został znaleziony.");
// Wykorzystanie metody biznesowej encji
order.AddItem(product, item.Quantity);
}
// Persystencja przez repozytorium
await _unitOfWork.Orders.AddAsync(order, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return order.Id;
}
}MediatR implementuje wzorzec Mediator, aby odseparować handlery od kontrolerów. Każdy command posiada jednego handler odpowiedzialnego za jego obsługę.
using MediatR;
namespace CleanArchitecture.Application.Orders.Queries;
// Query do pobrania zamówienia po ID
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;
// DTO odpowiedzi dla zamówienia
public record OrderDto(
Guid Id,
string CustomerEmail,
string Status,
decimal TotalAmount,
DateTime CreatedAt,
DateTime? ShippedAt,
List<OrderItemResponseDto> Items
);
// DTO dla pozycji w odpowiedzi
public record OrderItemResponseDto(
Guid Id,
string ProductName,
decimal UnitPrice,
int Quantity,
decimal TotalPrice
);using MediatR;
using CleanArchitecture.Domain.Interfaces;
namespace CleanArchitecture.Application.Orders.Queries;
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
private readonly IOrderRepository _orderRepository;
public GetOrderByIdQueryHandler(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task<OrderDto?> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
// Pobranie wraz z pozycjami
var order = await _orderRepository
.GetByIdWithItemsAsync(request.OrderId, cancellationToken);
if (order == null)
return null;
// Mapowanie do DTO (ręczna projekcja)
return new OrderDto(
Id: order.Id,
CustomerEmail: order.CustomerEmail,
Status: order.Status.ToString(),
TotalAmount: order.TotalAmount,
CreatedAt: order.CreatedAt,
ShippedAt: order.ShippedAt,
Items: order.Items.Select(i => new OrderItemResponseDto(
Id: i.Id,
ProductName: i.ProductName,
UnitPrice: i.UnitPrice,
Quantity: i.Quantity,
TotalPrice: i.TotalPrice
)).ToList()
);
}
}Rozdzielenie Commands/Queries odpowiada wzorcowi CQRS (Command Query Responsibility Segregation), optymalizując odczyty i zapisy niezależnie od siebie.
Walidacja z FluentValidation
Walidacja commandów odbywa się w warstwie Application, przed wykonaniem handlera.
using FluentValidation;
using CleanArchitecture.Application.Orders.Commands;
namespace CleanArchitecture.Application.Orders.Validators;
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
// Wymagany e-mail i poprawny format
RuleFor(x => x.CustomerEmail)
.NotEmpty().WithMessage("Adres e-mail klienta jest wymagany.")
.EmailAddress().WithMessage("Nieprawidłowy format adresu e-mail.");
// Wymagana co najmniej jedna pozycja
RuleFor(x => x.Items)
.NotEmpty().WithMessage("Zamówienie musi zawierać co najmniej jedną pozycję.");
// Walidacja każdej pozycji
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId)
.NotEmpty().WithMessage("Identyfikator produktu jest wymagany.");
item.RuleFor(i => i.Quantity)
.GreaterThan(0).WithMessage("Ilość musi być dodatnia.")
.LessThanOrEqualTo(100).WithMessage("Maksymalna ilość: 100.");
});
}
}using FluentValidation;
using MediatR;
namespace CleanArchitecture.Application.Common.Behaviors;
// Pipeline behavior do automatycznej walidacji
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Jeśli brak walidatorów, kontynuuj
if (!_validators.Any())
return await next();
// Wykonanie wszystkich walidatorów
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
// Agregacja błędów
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
// Wyrzucenie wyjątku w razie błędów
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}ValidationBehavior wykonuje się automatycznie przed każdym handlerem, gwarantując, że tylko poprawne commandy trafiają do logiki biznesowej.
Warstwa Infrastructure: implementacja techniczna
Warstwa Infrastructure dostarcza konkretne implementacje interfejsów zdefiniowanych w Domain i Application.
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;
namespace CleanArchitecture.Infrastructure.Persistence;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Zastosowanie konfiguracji z assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using CleanArchitecture.Domain.Entities;
namespace CleanArchitecture.Infrastructure.Persistence.Configurations;
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
// Tabela i klucz główny
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
// Właściwości
builder.Property(o => o.CustomerEmail)
.IsRequired()
.HasMaxLength(256);
builder.Property(o => o.Status)
.IsRequired()
.HasConversion<string>(); // Zapis jako czytelny string
// Relacja z OrderItems
builder.HasMany(o => o.Items)
.WithOne()
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
// Indeks dla wyszukiwania po e-mailu
builder.HasIndex(o => o.CustomerEmail);
// Dostęp do prywatnego pola _items
builder.Navigation(o => o.Items)
.UsePropertyAccessMode(PropertyAccessMode.Field);
}
}using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;
namespace CleanArchitecture.Infrastructure.Repositories;
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public OrderRepository(AppDbContext context)
{
_context = context;
}
public async Task<Order?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return await _context.Orders
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
public async Task<Order?> GetByIdWithItemsAsync(
Guid id,
CancellationToken cancellationToken = default)
{
// Eager loading pozycji
return await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
public async Task<IEnumerable<Order>> GetByCustomerEmailAsync(
string email,
CancellationToken cancellationToken = default)
{
return await _context.Orders
.Include(o => o.Items)
.Where(o => o.CustomerEmail == email)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(cancellationToken);
}
public async Task AddAsync(
Order order,
CancellationToken cancellationToken = default)
{
await _context.Orders.AddAsync(order, cancellationToken);
}
public Task UpdateAsync(
Order order,
CancellationToken cancellationToken = default)
{
// EF Core automatycznie śledzi zmiany
_context.Orders.Update(order);
return Task.CompletedTask;
}
public async Task DeleteAsync(
Guid id,
CancellationToken cancellationToken = default)
{
await _context.Orders
.Where(o => o.Id == id)
.ExecuteDeleteAsync(cancellationToken);
}
}Repozytoria nie powinny bezpośrednio udostępniać IQueryable, ponieważ tworzyłoby to zależność od EF Core w wyższych warstwach. Lepiej stosować specyficzne metody z jasnymi parametrami.
Implementacja Unit of Work
using Microsoft.EntityFrameworkCore.Storage;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Repositories;
namespace CleanArchitecture.Infrastructure.Persistence;
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
private IDbContextTransaction? _transaction;
// Lazy loading repozytoriów
private IOrderRepository? _orderRepository;
private IProductRepository? _productRepository;
public UnitOfWork(AppDbContext context)
{
_context = context;
}
// Tworzenie na żądanie (lazy initialization)
public IOrderRepository Orders =>
_orderRepository ??= new OrderRepository(_context);
public IProductRepository Products =>
_productRepository ??= new ProductRepository(_context);
public async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
}
public async Task BeginTransactionAsync(
CancellationToken cancellationToken = default)
{
_transaction = await _context.Database
.BeginTransactionAsync(cancellationToken);
}
public async Task CommitTransactionAsync(
CancellationToken cancellationToken = default)
{
if (_transaction == null)
throw new InvalidOperationException("Brak aktywnej transakcji.");
await _transaction.CommitAsync(cancellationToken);
await _transaction.DisposeAsync();
_transaction = null;
}
public async Task RollbackTransactionAsync(
CancellationToken cancellationToken = default)
{
if (_transaction == null)
throw new InvalidOperationException("Brak aktywnej transakcji.");
await _transaction.RollbackAsync(cancellationToken);
await _transaction.DisposeAsync();
_transaction = null;
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}Konfiguracja Dependency Injection
Rejestracja usług odbywa się w każdej warstwie poprzez metody rozszerzeń, a następnie jest orkiestrowana w pliku Program.cs.
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;
using CleanArchitecture.Infrastructure.Repositories;
namespace CleanArchitecture.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// Konfiguracja Entity Framework
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
// Rejestracja Unit of Work (Scoped)
services.AddScoped<IUnitOfWork, UnitOfWork>();
// Pojedyncze repozytoria w razie potrzeby
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IProductRepository, ProductRepository>();
return services;
}
}using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Application.Common.Behaviors;
namespace CleanArchitecture.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
var assembly = typeof(DependencyInjection).Assembly;
// Rejestracja MediatR z handlerami
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
// Rejestracja walidatorów FluentValidation
services.AddValidatorsFromAssembly(assembly);
// Pipeline behaviors (kolejność wykonywania ma znaczenie)
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
return services;
}
}using CleanArchitecture.Application;
using CleanArchitecture.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
// Rejestracja warstw
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// Konfiguracja API
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();Warstwa Presentation: kontrolery API
Kontrolery są prostymi adapterami, które delegują pracę do MediatR.
using MediatR;
using Microsoft.AspNetCore.Mvc;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Application.Orders.Queries;
namespace CleanArchitecture.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
[ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(
[FromBody] CreateOrderCommand command,
CancellationToken cancellationToken)
{
// Pełna delegacja do MediatR
var orderId = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(
nameof(GetById),
new { id = orderId },
orderId);
}
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(
Guid id,
CancellationToken cancellationToken)
{
var order = await _mediator.Send(
new GetOrderByIdQuery(id),
cancellationToken);
if (order == null)
return NotFound();
return Ok(order);
}
[HttpPost("{id:guid}/confirm")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Confirm(
Guid id,
CancellationToken cancellationToken)
{
await _mediator.Send(new ConfirmOrderCommand(id), cancellationToken);
return NoContent();
}
[HttpPost("{id:guid}/ship")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Ship(
Guid id,
CancellationToken cancellationToken)
{
await _mediator.Send(new ShipOrderCommand(id), cancellationToken);
return NoContent();
}
}Kontrolery pozostają cienkie i nie zawierają logiki biznesowej, jedynie mapowanie HTTP.
Testy jednostkowe warstwy Application
Clean Architecture ułatwia testy jednostkowe dzięki izolacji zależności.
using Moq;
using Xunit;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
namespace CleanArchitecture.Tests.Application;
public class CreateOrderCommandHandlerTests
{
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly CreateOrderCommandHandler _handler;
public CreateOrderCommandHandlerTests()
{
_unitOfWorkMock = new Mock<IUnitOfWork>();
_handler = new CreateOrderCommandHandler(_unitOfWorkMock.Object);
}
[Fact]
public async Task Handle_ValidCommand_CreatesOrderAndReturnsId()
{
// Arrange
var product = new Product { Id = Guid.NewGuid(), Name = "Test", Price = 100 };
_unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
It.IsAny<Guid>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(product);
_unitOfWorkMock.Setup(x => x.Orders.AddAsync(
It.IsAny<Order>(),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var command = new CreateOrderCommand(
CustomerEmail: "test@example.com",
Items: new List<OrderItemDto>
{
new(ProductId: product.Id, Quantity: 2)
});
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
Assert.NotEqual(Guid.Empty, result);
_unitOfWorkMock.Verify(x => x.Orders.AddAsync(
It.Is<Order>(o => o.CustomerEmail == "test@example.com"),
It.IsAny<CancellationToken>()), Times.Once);
_unitOfWorkMock.Verify(x => x.SaveChangesAsync(
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_ProductNotFound_ThrowsNotFoundException()
{
// Arrange
_unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
It.IsAny<Guid>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((Product?)null);
var command = new CreateOrderCommand(
CustomerEmail: "test@example.com",
Items: new List<OrderItemDto>
{
new(ProductId: Guid.NewGuid(), Quantity: 1)
});
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(
() => _handler.Handle(command, CancellationToken.None));
}
}Mocki Moq pozwalają odizolować handler od jego zależności i testować wyłącznie logikę orkiestracji.
Podsumowanie
Clean Architecture w .NET dostarcza solidnej struktury dla aplikacji enterprise. Ścisłe rozdzielenie odpowiedzialności między warstwami Domain, Application, Infrastructure i Presentation gwarantuje kod łatwy w utrzymaniu i testowaniu w długim okresie.
Checklista Clean Architecture .NET
- ✅ Odizolowany Domain bez zewnętrznych zależności
- ✅ Encje z enkapsulowaną logiką biznesową
- ✅ Interfejsy repozytoriów w Domain
- ✅ Use Cases poprzez Commands/Queries (CQRS)
- ✅ Walidacja z FluentValidation i Pipeline Behaviors
- ✅ Infrastructure implementuje interfejsy Domain
- ✅ Cienkie kontrolery delegujące do MediatR
- ✅ Odizolowane testy jednostkowe z mockami
Zacznij ćwiczyć!
Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.
Początkowa inwestycja w tę architekturę zwraca się szybko w średnich i dużych projektach, gdzie zmiany wymagań technicznych (migracja bazy danych, zmiana frameworka) wpływają wyłącznie na warstwy zewnętrzne, pozostawiając rdzeń biznesowy nienaruszony.
Tagi
Udostępnij
Powiązane artykuły

Pytania rekrutacyjne C# i .NET: Kompletny przewodnik 2026
17 najczesciej zadawanych pytan rekrutacyjnych z C# i .NET. LINQ, async/await, Dependency Injection, Entity Framework Core i ASP.NET Core ze szczegolowymi odpowiedziami.

.NET 8: Budowanie API z ASP.NET Core
Kompletny przewodnik po tworzeniu profesjonalnego REST API z .NET 8 i ASP.NET Core. Kontrolery, Entity Framework Core, walidacja i najlepsze praktyki.

.NET 9 Blazor: Pełnostackowy rozwój aplikacji z Blazor United w 2026
.NET 9 Blazor United łączy statyczny SSR, Server i WebAssembly w jednym frameworku full-stack. Praktyczny poradnik obejmujący tryby renderowania, streaming rendering, wstrzykiwanie zależności i wzorce produkcyjne.