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.

Przewodnik po Clean Architecture w .NET i C#

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.

Dlaczego Clean Architecture?

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.

text
┌─────────────────────────────────────────────────────────┐
│                     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.

bash
# 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.Infrastructure

Taka 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.

Domain/Entities/Order.cscsharp
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.

Domain/Entities/OrderItem.cscsharp
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;
    }
}
Domain/Enums/OrderStatus.cscsharp
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

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.

Domain/Interfaces/IOrderRepository.cscsharp
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);
}
Domain/Interfaces/IUnitOfWork.cscsharp
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.

Application/Orders/Commands/CreateOrderCommand.cscsharp
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
);
Application/Orders/Commands/CreateOrderCommandHandler.cscsharp
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ę.

Application/Orders/Queries/GetOrderByIdQuery.cscsharp
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
);
Application/Orders/Queries/GetOrderByIdQueryHandler.cscsharp
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.

Application/Orders/Validators/CreateOrderCommandValidator.cscsharp
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.");
        });
    }
}
Application/Common/Behaviors/ValidationBehavior.cscsharp
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.

Infrastructure/Persistence/AppDbContext.cscsharp
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);
    }
}
Infrastructure/Persistence/Configurations/OrderConfiguration.cscsharp
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);
    }
}
Infrastructure/Repositories/OrderRepository.cscsharp
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);
    }
}
Uwaga na Leaky Abstractions

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

Infrastructure/Persistence/UnitOfWork.cscsharp
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.

Infrastructure/DependencyInjection.cscsharp
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;
    }
}
Application/DependencyInjection.cscsharp
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;
    }
}
Api/Program.cscsharp
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.

Api/Controllers/OrdersController.cscsharp
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.

Tests/Application/CreateOrderCommandHandlerTests.cscsharp
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

#dotnet
#clean architecture
#csharp
#aspnet core
#design patterns

Udostępnij

Powiązane artykuły