Clean Architecture w .NET 2026: CQRS, MediatR i pytania rekrutacyjne

Praktyczny przewodnik po Clean Architecture w .NET z CQRS i MediatR, zawierający przykłady kodu i pytania rekrutacyjne.

Clean Architecture w .NET 2026: CQRS, MediatR i pytania rekrutacyjne

Clean Architecture w .NET oddziela logikę biznesową od warstw infrastrukturalnych, co sprawia, że aplikacje stają się łatwiejsze w testowaniu, utrzymaniu i skalowaniu. W połączeniu z wzorcem CQRS (Command Query Responsibility Segregation) oraz biblioteką MediatR, ta architektura zapewnia ustrukturyzowane podejście do budowania aplikacji korporacyjnych, które jest często weryfikowane podczas rozmów rekrutacyjnych.

Niezbędna wiedza rekrutacyjna

Pytania o Clean Architecture pojawiają się w 70% rozmów na stanowiska senior .NET developer. Rekruterzy oczekują, że kandydaci wyjaśnią regułę zależności: zależności wskazują do wewnątrz, a warstwa domenowa nie ma żadnych zależności od zewnętrznych frameworków.

Warstwy Clean Architecture i reguła zależności

Szablon Ardalis Clean Architecture dla ASP.NET Core 10 organizuje kod w cztery koncentryczne warstwy. Każda warstwa ma określone odpowiedzialności i ścisłe reguły zależności.

csharp
// Core/Domain Layer - No external dependencies
// src/Core/Domain/Entities/Order.cs
namespace Core.Domain.Entities;

public class Order
{
    public Guid Id { get; private set; }
    public string CustomerEmail { get; private set; }
    public List<OrderLine> Lines { get; private set; } = new();
    public OrderStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }

    // Domain logic encapsulated in the entity
    public void AddLine(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify a submitted order");

        var existingLine = Lines.FirstOrDefault(l => l.ProductId == product.Id);
        if (existingLine is not null)
        {
            existingLine.IncreaseQuantity(quantity);
            return;
        }

        Lines.Add(new OrderLine(product.Id, product.Price, quantity));
    }

    public void Submit()
    {
        if (!Lines.Any())
            throw new InvalidOperationException("Cannot submit an empty order");

        Status = OrderStatus.Submitted;
    }
}

Warstwa domenowa zawiera encje, obiekty wartości i serwisy domenowe. Nie ma żadnej wiedzy o bazach danych, protokole HTTP ani zewnętrznych frameworkach.

Wzorzec CQRS: Separacja odczytów od zapisów

CQRS dzieli operacje na komendy (operacje zapisu zmieniające stan) i zapytania (operacje odczytu zwracające dane). Ta separacja pozwala na niezależne skalowanie i optymalizację każdej ścieżki.

csharp
// UseCases Layer - Commands and Queries
// src/UseCases/Orders/Commands/CreateOrder/CreateOrderCommand.cs
namespace UseCases.Orders.Commands.CreateOrder;

public record CreateOrderCommand(
    string CustomerEmail,
    List<OrderLineDto> Lines
) : IRequest<Result<Guid>>;

public record OrderLineDto(Guid ProductId, int Quantity);

Komendy zwracają minimalne dane, zazwyczaj tylko wskaźnik sukcesu lub identyfikator nowo utworzonego obiektu. Zapytania zwracają DTO zoptymalizowane dla konsumenta.

src/UseCases/Orders/Queries/GetOrderById/GetOrderByIdQuery.cscsharp
namespace UseCases.Orders.Queries.GetOrderById;

public record GetOrderByIdQuery(Guid OrderId) : IRequest<Result<OrderDetailsDto>>;

public record OrderDetailsDto(
    Guid Id,
    string CustomerEmail,
    string Status,
    decimal TotalAmount,
    List<OrderLineDetailsDto> Lines
);

MediatR 14: Pipeline Behaviors i implementacja handlerów

MediatR 14.2 dostarcza infrastrukturę komunikacji dla CQRS. Każda komenda lub zapytanie ma dokładnie jeden handler, co wymusza zasadę pojedynczej odpowiedzialności.

src/UseCases/Orders/Commands/CreateOrder/CreateOrderHandler.cscsharp
namespace UseCases.Orders.Commands.CreateOrder;

public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderHandler(
        IOrderRepository orderRepository,
        IProductRepository productRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<Guid>> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Load products to validate and get prices
        var productIds = request.Lines.Select(l => l.ProductId).ToList();
        var products = await _productRepository
            .GetByIdsAsync(productIds, cancellationToken);

        if (products.Count != productIds.Count)
            return Result.NotFound("One or more products not found");

        // Create order using domain logic
        var order = new Order(request.CustomerEmail);

        foreach (var line in request.Lines)
        {
            var product = products.First(p => p.Id == line.ProductId);
            order.AddLine(product, line.Quantity);
        }

        await _orderRepository.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success(order.Id);
    }
}

Gotowy na rozmowy o .NET?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Pipeline Behaviors dla przekrojowych zagadnień

Pipeline behaviors w MediatR obsługują logowanie, walidację, cache oraz zarządzanie transakcjami bez zanieczyszczania handlerów dodatkowym kodem.

src/UseCases/Common/Behaviors/ValidationBehavior.cscsharp
namespace UseCases.Common.Behaviors;

public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    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)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

Rejestracja behaviors odbywa się w kolejności ich wykonywania. Walidacja zazwyczaj uruchamia się jako pierwsza, następnie logowanie, a na końcu obsługa transakcji.

src/Web/Program.cscsharp
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
    cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
    cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});

Warstwa infrastruktury: Implementacja repozytoriów

Warstwa infrastruktury implementuje interfejsy zdefiniowane w warstwie Core. Repozytoria Entity Framework Core tłumaczą operacje domenowe na wywołania bazodanowe.

src/Infrastructure/Data/Repositories/OrderRepository.cscsharp
namespace Infrastructure.Data.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
            .Include(o => o.Lines)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task AddAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders.AddAsync(order, cancellationToken);
    }

    public async Task<IReadOnlyList<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Where(o => o.CustomerEmail == email)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(cancellationToken);
    }
}

Pytania rekrutacyjne: Co powinien wiedzieć senior developer

Rozmowy techniczne na stanowiska senior .NET często zawierają pytania o Clean Architecture. Oto wzorce, które oceniają rekruterzy:

P: Dlaczego warstwa domenowa nie ma zależności od zewnętrznych frameworków?

Warstwa domenowa zawiera reguły biznesowe, które powinny pozostać stabilne niezależnie od zmian infrastrukturalnych. Jeśli domena zależy od Entity Framework, zmiana na Dapper lub inną bazę danych wymaga modyfikacji logiki biznesowej. Reguła zależności zapewnia, że infrastruktura jest detalem, który można wymienić bez wpływu na zachowanie główne.

P: Kiedy CQRS to przesada?

CQRS dodaje złożoność, której proste aplikacje CRUD nie potrzebują. Jeśli modele odczytu i zapisu są niemal identyczne, jeśli nie ma potrzeby oddzielnego skalowania, a zespół jest mały, prostsza architektura jest odpowiednia. CQRS sprawdza się, gdy modele odczytu znacząco różnią się od modeli zapisu, gdy wymagane jest event sourcing lub gdy obciążenia odczytu i zapisu wymagają niezależnego skalowania.

P: Jak obsługiwać transakcje między wieloma agregatami?

csharp
// Transaction behavior wraps the entire handler execution
public class TransactionBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IUnitOfWork _unitOfWork;

    public TransactionBehavior(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // Commands that modify multiple aggregates use explicit transactions
        if (request is not ICommand)
            return await next();

        await using var transaction = await _unitOfWork
            .BeginTransactionAsync(cancellationToken);

        try
        {
            var response = await next();
            await transaction.CommitAsync(cancellationToken);
            return response;
        }
        catch
        {
            await transaction.RollbackAsync(cancellationToken);
            throw;
        }
    }
}

Dla operacji obejmujących wiele agregatów należy stosować eventual consistency z domain events, gdy to możliwe. Synchroniczne transakcje między agregatami wskazują na potencjalny problem projektowy: agregaty mogą należeć do siebie.

Optymalizacja odczytów z oddzielnymi modelami zapytań

Zapytania omijają całkowicie warstwę domenową, gdy liczy się wydajność. Bezpośredni dostęp do bazy danych za pomocą Dapper lub surowego SQL unika narzutu materializacji pełnych grafów encji.

src/Infrastructure/Queries/GetOrderByIdQueryHandler.cscsharp
namespace Infrastructure.Queries;

public class GetOrderByIdQueryHandler
    : IRequestHandler<GetOrderByIdQuery, Result<OrderDetailsDto>>
{
    private readonly IDbConnection _connection;

    public GetOrderByIdQueryHandler(IDbConnection connection)
    {
        _connection = connection;
    }

    public async Task<Result<OrderDetailsDto>> Handle(
        GetOrderByIdQuery request,
        CancellationToken cancellationToken)
    {
        const string sql = """
            SELECT o.Id, o.CustomerEmail, o.Status, o.CreatedAt,
                   SUM(ol.UnitPrice * ol.Quantity) AS TotalAmount
            FROM Orders o
            LEFT JOIN OrderLines ol ON ol.OrderId = o.Id
            WHERE o.Id = @OrderId
            GROUP BY o.Id, o.CustomerEmail, o.Status, o.CreatedAt
            """;

        var order = await _connection.QueryFirstOrDefaultAsync<OrderDetailsDto>(
            new CommandDefinition(sql, new { request.OrderId }, cancellationToken: cancellationToken));

        return order is null
            ? Result.NotFound($"Order {request.OrderId} not found")
            : Result.Success(order);
    }
}

Zacznij ćwiczyć!

Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.

Przygotowanie do rozmowy rekrutacyjnej: Kluczowe wnioski

  • Reguła zależności stanowi, że zależności kodu źródłowego mogą wskazywać tylko do wewnątrz. Warstwa domenowa nie ma wiedzy o bazach danych, frameworkach ani mechanizmach dostarczania.
  • CQRS separuje komendy (zmiany stanu) od zapytań (pobieranie danych). Komendy przechodzą przez model domenowy i walidację. Zapytania mogą omijać encje domenowe dla wydajności.
  • MediatR 14 kieruje żądania do handlerów. Pipeline behaviors obsługują przekrojowe zagadnienia jak walidacja, logowanie i transakcje w sposób kompozycyjny.
  • Interfejsy repozytoriów znajdują się w warstwie Core. Implementacje znajdują się w infrastrukturze. Ta inwersja pozwala na zamianę EF Core na Dapper bez dotykania logiki biznesowej.
  • Odpowiedzi rekrutacyjne powinny zawierać kompromisy. Clean Architecture dodaje początkową złożoność, ale zwraca się w dużych, długożyjących aplikacjach z wieloma programistami.
  • Moduł ASP.NET Core Clean Architecture na SharpSkill pokrywa dodatkowe wzorce, w tym Specification, obiekty Result i Domain Events.
Wyzwanie dnia

Znajdziesz błąd w .NET?

Prawdziwy fragment kodu, ukryty błąd, jedna próba dziennie. Bez konta, żeby spróbować.

Anthony Fillion-Maillet

Autor:

Anthony Fillion-Maillet

Założyciel SharpSkill

Programista fullstack od ponad 10 lat. Prowadzi SharpSkill i odpowiada za wszystko, co się tu ukazuje.

Zaktualizowano 15 września 2026

Udostępnij

Powiązane artykuły