Przejdź do treści głównej

Clean Architecture i Domain-Driven Design w Praktyce 2025

Kompletny przewodnik po Clean Architecture i Domain-Driven Design: warstwy aplikacji, Bounded Contexts, Aggregates, tactical i strategic patterns, CQRS, Event Sourcing. Praktyczne przykłady implementacji w .NET i TypeScript dla enterprise applications.

Autor: Michał Wojciechowski··15 min czytania
Clean Architecture - diagramy i struktury systemów

Każdy senior developer spotkał się z tym problemem: kod, który zaczynał jako elegancki i prosty, po latach staje się niemożliwy do utrzymania. Business logic rozrzucona po controllerach, frameworki wnikają w każdy zakamarek aplikacji, testy wymagają uruchomienia całej infrastruktury. Zmiana bazy danych lub frameworka to projekt na kwartał.

Clean Architecture i Domain-Driven Design to odpowiedź na te problemy. Nie są to nowe koncepcje - Eric Evans opublikował "Domain-Driven Design" w 2003, Robert C. Martin (Uncle Bob) skodyfikował Clean Architecture w 2012. Ale w 2025 te wzorce są bardziej aktualne niż kiedykolwiek.

Dlaczego Clean Architecture w 2025?

  • Technologie się zmieniają - frameworki przychodzą i odchodzą, business logic pozostaje
  • Mikrousługi wymagają dobrych granic - DDD strategic patterns definiują Bounded Contexts
  • Testowanie musi być szybkie - unit testy domeny bez infrastruktury w milisekundach
  • Teams rosną - clear boundaries pozwalają na parallel development
  • Maintenance cost rośnie wykładniczo - dobre fundamenty to inwestycja

01Fundamenty Clean Architecture

Clean Architecture opiera się na kilku fundamentalnych zasadach, które Robert Martin zidentyfikował jako kluczowe dla maintainable software. Wszystkie one służą jednemu celowi: odseparowanie business logic od technical concerns.

Zasada Dependency Inversion (najważniejsza)

Zależności w kodzie powinny być skierowane do wewnątrz - od infrastructure do domeny, nigdy odwrotnie. Domain layer nie wie nic o bazach danych, frameworkach czy zewnętrznych API.

// ❌ ZŁE: Domain zależy od infrastructure
public class OrderService
{
    private readonly SqlConnection _db;  // Direct DB dependency!

    public void CreateOrder(Order order)
    {
        _db.Execute("INSERT INTO Orders...");  // Domain knows SQL!
    }
}

// ✅ DOBRE: Domain definiuje interface, infrastructure implementuje
public interface IOrderRepository  // Domain interface
{
    Task SaveAsync(Order order);
}

public class OrderService
{
    private readonly IOrderRepository _repository;  // Abstraction!

    public async Task CreateOrder(Order order)
    {
        await _repository.SaveAsync(order);  // Domain logic tylko
    }
}

// Infrastructure implementuje:
public class SqlOrderRepository : IOrderRepository
{
    // SQL details tutaj, domain tego nie widzi
}

Separation of Concerns

Każda warstwa ma swoją odpowiedzialność i nie ingeruje w inne:

  • Domain - business rules
  • Application - use cases
  • Infrastructure - DB, API, files
  • Presentation - UI, controllers

Testability First

Dobra architektura to taka, którą łatwo testować:

  • Unit testy domeny bez mock
  • Integration testy z in-memory DB
  • Application logic bez UI
  • Szybkie feedback loops

Uncle Bob o Clean Architecture:

"The outer circles are mechanisms. The inner circles are policies. The overriding rule that makes this architecture work is the Dependency Rule: Source code dependencies must point only inward, toward higher-level policies."

02Warstwy Clean Architecture

Warstwy architektury - koncepcyjna wizualizacja

Teraz, gdy rozumiesz fundamenty, zobaczmy jak to wygląda w praktyce. Clean Architecture dzieli aplikację na cztery główne warstwy układające się w koncentryczne koła. Im bliżej centrum, tym bardziej abstract i stable kod. Zewnętrzne warstwy mogą się zmieniać - zmiana DB z SQL na MongoDB nie powinna wpływać na business logic.

1

Domain Layer (centrum)

Serce aplikacji. Zawiera business rules, które są niezależne od jakiejkolwiek technologii. Nie ma żadnych dependencies - wszystko zależy od domeny.

Zawartość:

  • Entities - obiekty z identity (Order, Customer, Product)
  • Value Objects - immutable concepts (Money, Email, Address)
  • Aggregates - clusters of entities with consistency boundaries
  • Domain Events - something happened in domain (OrderCreated)
  • Domain Services - logic that doesn't fit in entity
  • Specifications - reusable business rules
// C# - Domain Entity z business logic
public class Order : Entity<OrderId>  // Aggregate Root
{
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    public Money Total => _items
        .Select(i => i.UnitPrice * i.Quantity)
        .Aggregate(Money.Zero, (a, b) => a + b);

    // Business logic w domain
    public void AddItem(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify confirmed order");

        if (quantity <= 0)
            throw new DomainException("Quantity must be positive");

        var item = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (item != null)
            item.IncreaseQuantity(quantity);
        else
            _items.Add(new OrderItem(product.Id, quantity, product.Price));

        AddDomainEvent(new OrderItemAdded(Id, product.Id, quantity));
    }

    public void Confirm()
    {
        if (!_items.Any())
            throw new DomainException("Cannot confirm empty order");

        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmed(Id, Total));
    }
}
2

Application Layer

Orchestruje use cases. Pobiera dane przez repository interfaces (zdefiniowane w domenie), wykonuje business logic, zapisuje zmiany. Nie zawiera business rules - deleguje do domeny.

Zawartość:

  • Commands & Queries - CQRS pattern (CreateOrder command)
  • Handlers - orchestration logic dla use cases
  • DTOs - data transfer objects dla komunikacji z UI
  • Validators - input validation (FluentValidation)
  • Mappers - domain ↔ DTO mapping
  • Interfaces - dla external services (IEmailSender)
// C# - Application Command Handler (CQRS)
public class CreateOrderCommandHandler
    : ICommandHandler<CreateOrderCommand, OrderDto>
{
    private readonly IOrderRepository _orders;
    private readonly IProductRepository _products;
    private readonly IUnitOfWork _unitOfWork;

    public async Task<OrderDto> Handle(
        CreateOrderCommand command,
        CancellationToken ct)
    {
        // 1. Load aggregate
        var customer = await _customers.GetByIdAsync(
            command.CustomerId, ct);

        // 2. Create domain entity
        var order = Order.Create(customer.Id);

        // 3. Business logic (delegated to domain)
        foreach (var item in command.Items)
        {
            var product = await _products.GetByIdAsync(
                item.ProductId, ct);
            order.AddItem(product, item.Quantity);
        }

        // 4. Persist
        await _orders.AddAsync(order, ct);
        await _unitOfWork.SaveChangesAsync(ct);

        // 5. Return DTO (nie domain entity!)
        return OrderDto.FromDomain(order);
    }
}

// TypeScript - podobny pattern
export class CreateOrderHandler
  implements ICommandHandler<CreateOrderCommand>
{
  constructor(
    private orders: IOrderRepository,
    private products: IProductRepository,
    private unitOfWork: IUnitOfWork
  ) {}

  async execute(command: CreateOrderCommand): Promise<OrderDto> {
    const order = Order.create(command.customerId);

    for (const item of command.items) {
      const product = await this.products.getById(item.productId);
      order.addItem(product, item.quantity);
    }

    await this.orders.save(order);
    await this.unitOfWork.commit();

    return OrderDto.fromDomain(order);
  }
}
3

Infrastructure Layer

Implementacje techniczne - bazy danych, external APIs, file systems, message queues. Implementuje interfaces zdefiniowane w domain/application.

Zawartość:

  • Repository Implementations - EF Core, Dapper, MongoDB
  • DB Context - Entity Framework DbContext
  • External Services - email, SMS, payment gateways
  • Caching - Redis, in-memory cache
  • Message Brokers - RabbitMQ, Kafka publishers
  • Logging - Serilog, Application Insights
// C# - Infrastructure Repository Implementation
public class EfOrderRepository : IOrderRepository  // implements domain interface
{
    private readonly AppDbContext _context;

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
    {
        return await _context.Orders
            .Include(o => o.Items)  // EF navigation
            .FirstOrDefaultAsync(o => o.Id == id, ct);
    }

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

// EF Core configuration (infrastructure concern)
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);

        builder.Property(o => o.Id)
            .HasConversion(id => id.Value, value => new OrderId(value));

        builder.OwnsMany(o => o.Items, items =>
        {
            items.ToTable("OrderItems");
            items.WithOwner().HasForeignKey("OrderId");
            items.Property(i => i.UnitPrice)
                .HasConversion(
                    money => money.Amount,
                    amount => new Money(amount));
        });
    }
}
4

Presentation Layer

Interfejs użytkownika - API controllers, Razor Pages, React components, GraphQL resolvers. Przekazuje input do application layer, formatuje output dla users.

// ASP.NET Core - API Controller (thin!)
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;  // MediatR dla CQRS

    [HttpPost]
    public async Task<ActionResult<OrderDto>> CreateOrder(
        [FromBody] CreateOrderRequest request,
        CancellationToken ct)
    {
        // Validation przez FluentValidation (middleware)
        var command = new CreateOrderCommand(
            request.CustomerId,
            request.Items);

        // Delegate to application layer
        var result = await _mediator.Send(command, ct);

        return CreatedAtAction(
            nameof(GetOrder),
            new { id = result.Id },
            result);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<OrderDto>> GetOrder(
        Guid id,
        CancellationToken ct)
    {
        var query = new GetOrderQuery(id);
        var result = await _mediator.Send(query, ct);

        return result is null ? NotFound() : Ok(result);
    }
}

Dependency Flow:

Presentation → Application → Domain ← Infrastructure

Infrastructure NIE jest na dole - jest obok. Implements interfaces defined by domain. To jest kwintesencja Dependency Inversion. Z mojego doświadczenia - to najtrudniejsza rzecz do wytłumaczenia juniorom. Wszyscy są przyzwyczajeni, że "baza danych jest na dole", a tu nagle domain definiuje interface IOrderRepository, a infrastructure tylko go implementuje. Ale kiedy to kliknie, wszystko inne staje się proste.

03DDD Strategic Patterns

Strategic planning - zespół definiujący granice kontekstów

Dobrze, mamy już warstwy aplikacji. Ale zanim zaczniesz pisać kod, potrzebujesz większego obrazu. Strategic DDD to patterns na poziomie całego systemu - musisz zrozumieć, jak podzielić domenę biznesową na logiczne kawałki. Bez tego nawet najlepsza implementacja warstw będzie chaotyczna. Eric Evans nazywa to "przestrzenią problemu" - zrozumienie domeny biznesowej i jej podziału.

Bounded Context - najważniejszy pattern

Bounded Context to granica, w której konkretny model domeny jest ważny. To samo słowo ("Customer", "Order") może znaczyć co innego w różnych kontekstach.

Sales Context

Customer: ma shopping history, preferences, wishlist. Order: agreguje items, calculating total, applying discounts.

Fulfillment Context

Customer: to shipping address tylko. Order: list of items to pack, shipping method, tracking number.

Accounting Context

Customer: billing info, credit limit. Order: invoice, payment status, accounting entries, tax calculation.

W monolicie - to osobne moduły. W mikrousługach - osobne serwisy. Bounded Context = deployment unit boundary.

Ubiquitous Language

Wspólny język między developerami a domain experts. Kod używa dokładnie tych samych terminów co biznes. Nie tłumaczysz - używasz ich słownictwa.

❌ Bez Ubiquitous Language:

class CustomerData {
  string Name;
  List<PurchaseRecord> Buys;
  bool IsVip;
}

Developer language, biznes nie rozumie

✅ Z Ubiquitous Language:

class Customer {
  CustomerName Name;
  OrderHistory Orders;
  LoyaltyTier Tier;
}

Dokładne słowa z business glossary

Context Mapping

Jak różne Bounded Contexts się komunikują? Eric Evans zidentyfikował kilka patterns relacji:

Partnership

Dwa konteksty są silnie związane, teams koordynują zmiany. Używaj gdy: tight coupling biznesowo jest nieunikniony. Przykład: Orders ↔ Payments.

Customer-Supplier

Upstream team (supplier) dostarcza API, downstream team (customer) go używa. Supplier bierze feedback od customera. Przykład: Inventory (supplier) → Sales (customer).

Conformist

Downstream team nie ma wpływu na upstream API - musi się dostosować. Przykład: integracja z external payment gateway. Twój kod konformuje się do ich modelu.

Anti-Corruption Layer (ACL)

Translacja layer chroniący Twój domain model od external systemu. Przykład: legacy system ma kiepski API - ACL tłumaczy to na clean domain objects. Nie pozwól legacy zainfekować nowego kodu!

Shared Kernel

Mały subset domeny współdzielony między kontekstami. Wymaga ścisłej koordynacji - zmiana affectuje oba teams. Use sparingly! Przykład: common Value Objects (Money, Address).

Praktyczne zastosowanie:

W modularnym monolicie - Bounded Contexts to moduły. W mikrousługach - osobne serwisy. Strategic DDD najpierw, deployment strategy potem.

Context Mapping pokazuje gdzie potrzebujesz integracji events, gdzie shared libraries, gdzie API calls. To blueprint dla architektury systemu. Pracowałem nad systemem e-commerce, gdzie pominięto Strategic DDD - efekt? Zamówienia mieszały się z fakturami, customer znaczył co innego w każdym module. Miesiąc pracy na Event Storming i Context Mapping zaoszczędził nam rok refaktoringu.

04DDD Tactical Patterns - Building Blocks

Mając zdefiniowane granice kontekstów (Strategic DDD), możemy zejść na poziom kodu. Tactical patterns to konkretne building blocks dla implementacji domain model - Entity, Value Object, Aggregate, Domain Event. To tutaj większość programistów popełnia błędy, więc przyjrzyjmy się im dokładnie. Większość confusion wokół DDD dotyczy właśnie tactical patterns.

Entity vs Value Object

Entity - ma identity

Obiekt z unique identity, który persists over time. Dwa Customery z tym samym name/email to różni Customerzy (różne ID).

public abstract class Entity<TId>
{
    public TId Id { get; protected set; }

    public override bool Equals(object obj)
    {
        if (obj is not Entity<TId> other)
            return false;

        return Id.Equals(other.Id);  // identity!
    }
}

public class Customer : Entity<CustomerId>
{
    public string Name { get; private set; }
    public Email Email { get; private set; }
    // Can change name/email, still same Customer
}

Value Object - no identity

Immutable, equality by value. Money(10, "USD") == Money(10, "USD"). Nie ma ID, nie trackujemy zmian - replace całość.

public record Money  // C# record = immutable VO
{
    public decimal Amount { get; init; }
    public string Currency { get; init; }

    public static Money operator +(Money a, Money b)
    {
        if (a.Currency != b.Currency)
            throw new InvalidOperationException();
        return new Money
        {
            Amount = a.Amount + b.Amount,
            Currency = a.Currency
        };
    }
}

// TypeScript
export class Money {
  constructor(
    readonly amount: number,
    readonly currency: string
  ) {}

  add(other: Money): Money {
    if (this.currency !== other.currency)
      throw new Error("Currency mismatch");
    return new Money(
      this.amount + other.amount,
      this.currency
    );
  }
}

Kiedy Entity, kiedy Value Object?

Pytanie: "Czy zmiana atrybutów tworzy nowy concept?". Address zmienia się - to Value Object (nowy address = nowy object). Customer zmienia address - to Entity (ten sam Customer, inny adres). Najczęstszy błąd? Robienie Entity z wszystkiego "bo ma ID w bazie". Money ma ID w bazie? Nie obchodzi mnie - to Value Object. Liczy się tylko czy concept ma identity w domenie biznesowej, nie w SQL.

Aggregates - Consistency Boundaries

Aggregate to cluster of entities/VOs traktowanych jako unit. Aggregate Root to single entity będący entry point. Zewnętrzne obiekty mogą referencować tylko root, nie internal entities.

Przykład: Order Aggregate

public class Order : Entity<OrderId>, IAggregateRoot  // Root!
{
    private readonly List<OrderItem> _items = new();  // internal entities
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    // All modifications przez Aggregate Root
    public void AddItem(ProductId productId, int quantity, Money price)
    {
        // Aggregate enforces invariants
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify confirmed order");

        var existingItem = _items.Find(i => i.ProductId == productId);
        if (existingItem != null)
            existingItem.IncreaseQuantity(quantity);  // internal call OK
        else
            _items.Add(new OrderItem(productId, quantity, price));
    }

    public void RemoveItem(OrderItemId itemId)
    {
        var item = _items.Find(i => i.Id == itemId);
        if (item == null)
            throw new DomainException("Item not found");

        _items.Remove(item);
    }
}

public class OrderItem : Entity<OrderItemId>  // Internal entity
{
    // No public constructor! Only through Order.AddItem()
    internal OrderItem(ProductId productId, int quantity, Money price)
    {
        ProductId = productId;
        Quantity = quantity;
        UnitPrice = price;
    }

    internal void IncreaseQuantity(int amount)  // internal!
    {
        Quantity += amount;
    }
}

// Repository działa na Aggregate Root
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id);  // loads całego aggregate
    Task AddAsync(Order order);  // saves całego aggregate
}

Zasady Aggregate Design:

  • Small aggregates - tylko entities które muszą być spójne razem
  • Reference by ID - nie trzymaj whole aggregate objects, tylko IDs
  • One transaction = one aggregate - nie modyfikuj wielu aggregates w jednej transakcji
  • Eventual consistency między aggregates - używaj Domain Events

Domain Events - Reactive Domain Logic

Domain Event to "something happened" w domenie. Pozwala na loose coupling - jedna część domeny reaguje na zmiany w innej bez direct dependency.

// Domain Event - immutable record
public record OrderConfirmedEvent(
    OrderId OrderId,
    CustomerId CustomerId,
    Money TotalAmount,
    DateTime ConfirmedAt
) : IDomainEvent;

// Aggregate raises event
public class Order : Entity<OrderId>
{
    public void Confirm()
    {
        if (!_items.Any())
            throw new DomainException("Empty order");

        Status = OrderStatus.Confirmed;
        ConfirmedAt = DateTime.UtcNow;

        // Add event to aggregate
        AddDomainEvent(new OrderConfirmedEvent(
            Id,
            CustomerId,
            Total,
            ConfirmedAt));
    }
}

// Event Handler - może być w innym Bounded Context!
public class OrderConfirmedEventHandler
    : IDomainEventHandler<OrderConfirmedEvent>
{
    private readonly IInventoryService _inventory;
    private readonly IEmailService _email;

    public async Task Handle(OrderConfirmedEvent evt, CancellationToken ct)
    {
        // Reserve inventory (different aggregate/context)
        await _inventory.ReserveItems(evt.OrderId, ct);

        // Send confirmation email (external service)
        await _email.SendOrderConfirmation(evt.CustomerId, evt.OrderId, ct);
    }
}

Domain Events vs Integration Events: Domain Events to wewnątrz Bounded Context. Integration Events to komunikacja między kontekstami (np. przez message broker).

Domain Services

Czasem business logic nie pasuje ani do Entity, ani do Value Object. Wtedy Domain Service. Stateless operations na domain objects.

// Domain Service - logic not belonging to single entity
public class PricingService : IDomainService
{
    public Money CalculateOrderTotal(Order order, Customer customer)
    {
        var subtotal = order.Items
            .Select(i => i.UnitPrice * i.Quantity)
            .Aggregate(Money.Zero, (a, b) => a + b);

        // Complex pricing logic involving multiple aggregates
        var discount = customer.LoyaltyTier switch
        {
            LoyaltyTier.Gold => subtotal * 0.10m,
            LoyaltyTier.Silver => subtotal * 0.05m,
            _ => Money.Zero
        };

        var shipping = CalculateShipping(order, customer.Address);
        var tax = CalculateTax(subtotal - discount, customer.Address);

        return subtotal - discount + shipping + tax;
    }
}

Specifications Pattern

Reusable business rules jako obiekty. Pozwala na composition i testowanie reguł niezależnie.

public interface ISpecification<T>
{
    bool IsSatisfiedBy(T entity);
}

public class OrderEligibleForFreeShippingSpec : ISpecification<Order>
{
    private readonly Money _threshold = new(100, "USD");

    public bool IsSatisfiedBy(Order order)
    {
        return order.Total >= _threshold;
    }
}

public class OrderFromPremiumCustomerSpec : ISpecification<Order>
{
    public bool IsSatisfiedBy(Order order)
    {
        return order.Customer.LoyaltyTier == LoyaltyTier.Gold;
    }
}

// Composition
var freeShippingSpec = new OrderEligibleForFreeShippingSpec()
    .Or(new OrderFromPremiumCustomerSpec());

if (freeShippingSpec.IsSatisfiedBy(order))
    order.ApplyFreeShipping();

05CQRS i Event Sourcing z Clean Architecture

CQRS pattern - separacja read i write models

Podstawowa Clean Architecture z DDD to solidny fundament. Ale czasem potrzebujesz więcej - zwłaszcza gdy system rośnie i pojawiają się problemy ze skalowalnością czy złożonością odczytów. CQRS (Command Query Responsibility Segregation) i Event Sourcing to advanced patterns, które często łączy się z Clean Architecture. Nie są obowiązkowe - większość aplikacji ich nie potrzebuje. Ale dla złożonych domen dają potężne możliwości. Przeczytaj także mój artykuł o Event-Driven Architecture.

CQRS - Separacja Read i Write

Traditional approach: ten sam model dla czytania i pisania. CQRS: osobny write model (domain entities) i read model (denormalized views optimized for queries).

Write Side (Commands)

  • • Domain entities z business logic
  • • Validates invariants
  • • Emits Domain Events
  • • Optimized for consistency
  • • Normalized schema

Read Side (Queries)

  • • Denormalized DTOs
  • • No business logic
  • • Optimized for fast reads
  • • Może być eventual consistent
  • • Różne storage (SQL + ElasticSearch)
// CQRS w Clean Architecture Application Layer

// WRITE: Command + Handler
public record CreateOrderCommand(
    Guid CustomerId,
    List<OrderItemDto> Items
) : ICommand<Guid>;

public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand, Guid>
{
    private readonly IOrderRepository _orders;

    public async Task<Guid> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Create(new CustomerId(cmd.CustomerId));
        // ... business logic
        await _orders.AddAsync(order, ct);
        return order.Id.Value;
    }
}

// READ: Query + Handler (bypasses domain!)
public record GetOrderDetailsQuery(Guid OrderId) : IQuery<OrderDetailsDto>;

public class GetOrderDetailsQueryHandler
    : IQueryHandler<GetOrderDetailsQuery, OrderDetailsDto>
{
    private readonly IOrderReadRepository _readRepo;  // optimized read repo

    public async Task<OrderDetailsDto> Handle(
        GetOrderDetailsQuery query,
        CancellationToken ct)
    {
        // Direct DB query, no domain hydration
        return await _readRepo.GetOrderDetails(query.OrderId, ct);
    }
}

// ReadRepository może używać Dapper zamiast EF:
public class OrderReadRepository : IOrderReadRepository
{
    public async Task<OrderDetailsDto> GetOrderDetails(Guid orderId, CancellationToken ct)
    {
        const string sql = @"
            SELECT o.Id, o.Total, o.Status, c.Name as CustomerName,
                   oi.ProductName, oi.Quantity, oi.Price
            FROM Orders o
            JOIN Customers c ON o.CustomerId = c.Id
            JOIN OrderItems oi ON o.Id = oi.OrderId
            WHERE o.Id = @OrderId";

        return await _connection.QueryAsync<OrderDetailsDto>(sql, new { orderId });
    }
}

Event Sourcing - History as Source of Truth

Traditional persistence: przechowujesz current state. Event Sourcing: przechowujesz sequence of events. Current state = replay all events.

Korzyści Event Sourcing:

  • Full audit trail - każda zmiana zapisana, nic nie ginie
  • Temporal queries - "jak wyglądał Order 3 miesiące temu?"
  • Event replay - rebuild state, test new projections
  • Debugging - replay events do reproduced buga
  • Analytics - event stream jako source dla ML/BI
// Event Sourced Aggregate
public class Order : EventSourcedAggregate<OrderId>
{
    public OrderStatus Status { get; private set; }
    private readonly List<OrderItem> _items = new();

    // Commands modify state through events
    public void AddItem(ProductId productId, int quantity, Money price)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify confirmed order");

        // Raise event (not direct state mutation!)
        RaiseEvent(new OrderItemAddedEvent(
            Id,
            productId,
            quantity,
            price));
    }

    public void Confirm()
    {
        if (!_items.Any())
            throw new DomainException("Empty order");

        RaiseEvent(new OrderConfirmedEvent(Id, DateTime.UtcNow, Total));
    }

    // State changes only in event handlers (deterministic!)
    private void Apply(OrderItemAddedEvent evt)
    {
        var item = _items.FirstOrDefault(i => i.ProductId == evt.ProductId);
        if (item != null)
            item.IncreaseQuantity(evt.Quantity);
        else
            _items.Add(new OrderItem(evt.ProductId, evt.Quantity, evt.Price));
    }

    private void Apply(OrderConfirmedEvent evt)
    {
        Status = OrderStatus.Confirmed;
        ConfirmedAt = evt.ConfirmedAt;
    }
}

// Event Store Repository
public class EventStoreOrderRepository : IOrderRepository
{
    private readonly IEventStore _eventStore;

    public async Task<Order> GetByIdAsync(OrderId id, CancellationToken ct)
    {
        var events = await _eventStore.GetEventsAsync(id.Value, ct);
        var order = new Order();
        order.LoadFromHistory(events);  // replay events!
        return order;
    }

    public async Task SaveAsync(Order order, CancellationToken ct)
    {
        var events = order.GetUncommittedEvents();
        await _eventStore.AppendEventsAsync(order.Id.Value, events, ct);
        order.MarkEventsAsCommitted();
    }
}

Kiedy NIE używać Event Sourcing:

  • • Prosta CRUD aplikacja - overhead nie jest worth it
  • • Team bez doświadczenia z eventual consistency
  • • Wymagania strict GDPR delete (hard to delete events)
  • • Queries na current state są głównym use case

Widziałem projekty, gdzie Event Sourcing był wprowadzony "bo fajnie brzmi". Efekt? Team walczył z bugami w event replay przez pół roku, zamiast dostarczać value biznesowi. Event Sourcing to potężne narzędzie, ale tylko gdy rzeczywiście rozwiązuje realny problem.

CQRS + Event Sourcing + Clean Architecture

Wszystkie trzy patterns świetnie ze sobą współpracują:

  • Domain Layer - event sourced aggregates z business logic
  • Application Layer - Commands (save events), Queries (read projections)
  • Infrastructure - Event Store (write), Read DB (projections)
  • Event Handlers - aktualizują read models z event stream

06Vertical Slice Architecture - alternatywa czy uzupełnienie?

Vertical Slice Architecture to newer approach popularyzowany przez Jimmy'ego Bogarda. Zamiast horizontal layers, każda feature to vertical slice zawierający wszystko potrzebne: UI, logic, data access. Sprzeczne z Clean Architecture?

Clean Architecture (Horizontal)

src/
├── Domain/
│   ├── Orders/
│   ├── Customers/
│   └── Products/
├── Application/
│   ├── Orders/
│   ├── Customers/
│   └── Products/
├── Infrastructure/
│   ├── Persistence/
│   └── ExternalServices/
└── Presentation/
    └── Controllers/

Separation by technical concerns. Shared domain logic. Reusability wysokie.

Vertical Slice (Features)

src/
├── Features/
│   ├── CreateOrder/
│   │   ├── Endpoint.cs
│   │   ├── Handler.cs
│   │   ├── Validator.cs
│   │   └── Repository.cs
│   ├── GetOrderDetails/
│   │   ├── Endpoint.cs
│   │   ├── Query.cs
│   │   └── Projection.cs
│   └── CancelOrder/
│       └── ...

Separation by features. Minimal coupling between slices. Low reusability.

Kiedy Vertical Slice, kiedy Clean Architecture?

VS
Vertical Slice gdy:

CRUD-heavy app, features niezależne, mały team, szybka iteracja. Przykład: admin panel, backoffice tools, simple REST APIs.

CA
Clean Architecture gdy:

Złożona domain logic, shared business rules, enterprise apps, długi lifecycle. Przykład: financial systems, e-commerce platforms, booking systems.

Hybrid Approach:

Nie musisz wybierać! Możesz mieć Vertical Slices WEWNĄTRZ Clean Architecture layers:

  • • Domain Layer - shared, reusable aggregates/VOs
  • • Application Layer - organized by features (slices)
  • • Infrastructure - shared implementations

Jimmy Bogard sam używa Vertical Slices na poziomie application, ale z shared domain layer. Best of both worlds. To moje ulubione podejście - dostajesz izolację features (łatwo usunąć/dodać funkcję) przy zachowaniu wspólnych reguł biznesowych. Używam tego w większości projektów i działa świetnie.

07Strategia testowania Clean Architecture

Testing strategy - automated tests

Największa korzyść Clean Architecture: testability. Domain logic bez dependencies = szybkie unit testy. Application logic z mockowaną infrastructure = integration testy bez slow I/O.

Test Pyramid dla Clean Architecture

Unit Tests (70%)

Domain entities, VOs, domain services. Bez mocków, bez DB.

[Test]
public void AddItem_WhenConfirmed_Throws()
{
  var order = Order.Create(customerId);
  order.Confirm();

  Assert.Throws<DomainException>(
    () => order.AddItem(productId, 1, price)
  );
}

Integration Tests (20%)

Application handlers z in-memory DB. Testcontainers dla real DB.

[Test]
public async Task CreateOrder_SavesCorrectly()
{
  var cmd = new CreateOrderCommand(...);
  var handler = new CreateOrderHandler(
    _orderRepo, _productRepo, _uow
  );

  var orderId = await handler.Handle(cmd, ct);

  var saved = await _orderRepo.GetByIdAsync(orderId);
  Assert.That(saved.Items.Count, Is.EqualTo(2));
}

E2E Tests (10%)

Całość przez API. Tylko critical paths.

[Test]
public async Task OrderFlow_E2E()
{
  var response = await _client.PostAsync(
    "/api/orders",
    JsonContent.Create(createOrderDto)
  );

  response.EnsureSuccessStatusCode();
  var order = await response.Content
    .ReadFromJsonAsync<OrderDto>();
  Assert.That(order.Status, Is.EqualTo("Draft"));
}

Architecture Tests

Wymuś dependency rules automatycznie. ArchUnitNET (.NET) lub ts-arch (TypeScript) walidują, że Infrastructure nie leaks do Domain.

// ArchUnitNET - enforce Clean Architecture rules
[Test]
public void Domain_ShouldNotDependOn_Infrastructure()
{
    var domain = Types().That().ResideInNamespace("Domain");
    var infrastructure = Types().That().ResideInNamespace("Infrastructure");

    var rule = domain.Should().NotDependOnAny(infrastructure);

    rule.Check(Architecture);
}

[Test]
public void Domain_ShouldNotDependOn_Application()
{
    var domain = Types().That().ResideInNamespace("Domain");
    var application = Types().That().ResideInNamespace("Application");

    domain.Should().NotDependOnAny(application).Check(Architecture);
}

[Test]
public void Controllers_ShouldNotDependOn_Domain()
{
    // Controllers should only depend on Application (MediatR)
    var controllers = Types().That().ResideInNamespace("Presentation.Controllers");
    var domain = Types().That().ResideInNamespace("Domain");

    controllers.Should().NotDependOnAny(domain).Check(Architecture);
}

Testing Strategy Checklist:

  • ✓ Domain unit tests bez mocków - najszybsze feedback
  • ✓ Application integration tests z in-memory DB - validate use cases
  • ✓ Architecture tests - enforce boundaries automatycznie
  • ✓ Contract tests - jeśli mikrousługi, testuj API contracts
  • ✓ E2E tests - tylko happy paths i critical flows

08Przykład implementacji w .NET i TypeScript

Teoria bez praktyki to nic. Pokazuję strukturę projektu i key files dla Clean Architecture w .NET i TypeScript.

.NET Clean Architecture Structure

Solution/
├── src/
│   ├── Domain/
│   │   ├── Orders/
│   │   │   ├── Order.cs (Aggregate Root)
│   │   │   ├── OrderItem.cs (Entity)
│   │   │   ├── OrderStatus.cs (Enum)
│   │   │   ├── Events/
│   │   │   │   └── OrderConfirmedEvent.cs
│   │   │   └── IOrderRepository.cs (Interface)
│   │   ├── Customers/
│   │   ├── Common/
│   │   │   ├── Entity.cs
│   │   │   ├── ValueObject.cs
│   │   │   └── DomainException.cs
│   │   └── Domain.csproj
│   │
│   ├── Application/
│   │   ├── Orders/
│   │   │   ├── Commands/
│   │   │   │   ├── CreateOrder/
│   │   │   │   │   ├── CreateOrderCommand.cs
│   │   │   │   │   ├── CreateOrderCommandHandler.cs
│   │   │   │   │   └── CreateOrderCommandValidator.cs
│   │   │   │   └── ConfirmOrder/
│   │   │   └── Queries/
│   │   │       └── GetOrderDetails/
│   │   │           ├── GetOrderDetailsQuery.cs
│   │   │           └── GetOrderDetailsQueryHandler.cs
│   │   ├── Common/
│   │   │   ├── Interfaces/
│   │   │   │   ├── ICommand.cs
│   │   │   │   ├── IQuery.cs
│   │   │   │   └── IUnitOfWork.cs
│   │   │   └── Behaviors/
│   │   │       ├── ValidationBehavior.cs
│   │   │       └── LoggingBehavior.cs
│   │   └── Application.csproj
│   │
│   ├── Infrastructure/
│   │   ├── Persistence/
│   │   │   ├── ApplicationDbContext.cs
│   │   │   ├── Repositories/
│   │   │   │   └── OrderRepository.cs
│   │   │   ├── Configurations/
│   │   │   │   └── OrderConfiguration.cs
│   │   │   └── Migrations/
│   │   ├── ExternalServices/
│   │   │   ├── EmailService.cs
│   │   │   └── PaymentGatewayService.cs
│   │   └── Infrastructure.csproj
│   │
│   └── Presentation/
│       ├── Controllers/
│       │   └── OrdersController.cs
│       ├── Program.cs
│       ├── appsettings.json
│       └── Presentation.csproj
│
└── tests/
    ├── Domain.Tests/
    ├── Application.Tests/
    └── Architecture.Tests/

Każda warstwa to osobny projekt (.csproj). Dependencies: Presentation → Application → Domain ← Infrastructure.

TypeScript Clean Architecture Structure

src/
├── domain/
│   ├── orders/
│   │   ├── order.entity.ts
│   │   ├── order-item.entity.ts
│   │   ├── order-status.enum.ts
│   │   ├── events/
│   │   │   └── order-confirmed.event.ts
│   │   └── order.repository.interface.ts
│   ├── customers/
│   └── common/
│       ├── entity.base.ts
│       ├── value-object.base.ts
│       └── domain.exception.ts
│
├── application/
│   ├── orders/
│   │   ├── commands/
│   │   │   ├── create-order/
│   │   │   │   ├── create-order.command.ts
│   │   │   │   ├── create-order.handler.ts
│   │   │   │   └── create-order.validator.ts
│   │   │   └── confirm-order/
│   │   └── queries/
│   │       └── get-order-details/
│   │           ├── get-order-details.query.ts
│   │           └── get-order-details.handler.ts
│   └── common/
│       ├── interfaces/
│       │   ├── command.interface.ts
│       │   ├── query.interface.ts
│       │   └── unit-of-work.interface.ts
│       └── decorators/
│           └── validate.decorator.ts
│
├── infrastructure/
│   ├── persistence/
│   │   ├── typeorm/
│   │   │   ├── typeorm.config.ts
│   │   │   ├── repositories/
│   │   │   │   └── order.repository.ts
│   │   │   └── entities/
│   │   │       └── order.schema.ts
│   │   └── prisma/
│   ├── external-services/
│   │   ├── email.service.ts
│   │   └── payment-gateway.service.ts
│   └── messaging/
│       └── rabbitmq.service.ts
│
├── presentation/
│   ├── http/
│   │   ├── controllers/
│   │   │   └── orders.controller.ts
│   │   ├── dtos/
│   │   └── middleware/
│   ├── graphql/
│   │   └── resolvers/
│   └── main.ts
│
└── tests/
    ├── domain/
    ├── application/
    └── e2e/

TypeScript z NestJS lub Express. Można użyć path aliases (@domain, @application) dla czystych imports.

Dependency Injection Setup

// .NET - Program.cs
var builder = WebApplication.CreateBuilder(args);

// Application Layer (MediatR for CQRS)
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly));
builder.Services.AddValidatorsFromAssembly(typeof(CreateOrderCommand).Assembly);

// Infrastructure Layer
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IEmailService, EmailService>();

// Domain Event Dispatcher
builder.Services.AddScoped<IDomainEventDispatcher, DomainEventDispatcher>();

var app = builder.Build();
app.MapControllers();
app.Run();

// TypeScript/NestJS - app.module.ts
@Module({
  imports: [
    TypeOrmModule.forRoot(typeOrmConfig),
    CqrsModule,  // NestJS CQRS
  ],
  providers: [
    // Repositories
    { provide: 'IOrderRepository', useClass: OrderRepository },
    { provide: 'IUnitOfWork', useClass: TypeOrmUnitOfWork },

    // Handlers
    CreateOrderHandler,
    GetOrderDetailsHandler,

    // Domain Services
    PricingService,
  ],
  controllers: [OrdersController],
})
export class AppModule {}

Najczęściej zadawane pytania

Czym jest Clean Architecture i dlaczego warto ją stosować?

Clean Architecture to wzorzec architektoniczny zaproponowany przez Roberta C. Martina (Uncle Bob), który separuje logikę biznesową od szczegółów technicznych. Największa korzyść? Twoja aplikacja nie jest zakładnikiem frameworka czy bazy danych. Zmiana z PostgreSQL na MongoDB, z REST API na GraphQL, z Angular na React - wszystko to możliwe bez przepisywania logiki biznesowej. Szczególnie opłaca się w projektach długoterminowych, gdzie wymagania się zmieniają, a zespół musi szybko wprowadzać nowe funkcjonalności bez ryzyka "rozwałenia" całego systemu.

Jaka jest różnica między Clean Architecture a Hexagonal Architecture?

Prawda jest taka, że różnice są minimalne - to dwa podejścia do tego samego problemu. Hexagonal Architecture (znana też jako Ports & Adapters) powstała wcześniej i używa metafory portów (interfejsy wejścia/wyjścia) oraz adapterów (ich implementacje). Clean Architecture przyszła później i nazwała warstwy wprost: Domain, Application, Infrastructure, Presentation. W praktyce? Implementujesz to samo - izolujesz logikę biznesową od zewnętrznych zależności. Wybierz tę nazwę, która lepiej brzmi w Twoim zespole.

Czym się różni tactical DDD od strategic DDD?

To jak różnica między planem miasta a projektem budynku. Strategic DDD to patrzenie na system z lotu ptaka - definiujesz Bounded Contexts (granice między modułami), Context Mapping (jak te moduły się komunikują), Ubiquitous Language (wspólny język z biznesem). Tactical DDD to schodzenie na poziom kodu - Aggregates, Entities, Value Objects, Domain Events. Najczęstszy błąd? Ludzie zaczynają od tactical patterns i tworzą piękne Aggregates... które nie mają sensu biznesowego, bo nikt nie zdefiniował granic kontekstów. Zawsze zacznij od strategic - to fundament, na którym budujesz tactical patterns.

Kiedy stosować CQRS z Clean Architecture?

CQRS to rozdzielenie operacji zapisu od odczytu - osobny model do tworzenia zamówień, osobny do ich wyświetlania. Warto, gdy masz asymetrię: dużo odczytów, mało zapisów (albo odwrotnie), złożone raporty wymagające denormalizacji, lub różne wymagania skalowania dla read/write. Przykład? E-commerce: zapisujesz zamówienie do znormalizowanej bazy, ale dashboard wymaga agregacji z 10 tabel - CQRS pozwala mieć osobny, zdenormalizowany read model tylko do raportów. NIE stosuj dla prostego CRUD - to overengineering. Jeśli formularz edycji wygląda tak samo jak widok szczegółów, CQRS to za dużo.

Clean Architecture vs Vertical Slice - który wybrać?

To zależy od charakteru aplikacji. Clean Architecture sprawdza się, gdy masz wspólną logikę biznesową wykorzystywaną w wielu miejscach - np. system finansowy, gdzie reguły walidacji czy obliczeń są reużywane. Vertical Slice Architecture jest lepsza dla aplikacji, gdzie każda funkcja jest praktycznie niezależna - panel administracyjny, backoffice, gdzie każda sekcja to osobny świat. Moja rekomendacja? Zacznij od Clean Architecture dla core domain (gdzie jest prawdziwa logika), a Vertical Slices dla supporting/generic features. I pamiętaj - możesz je łączyć: Vertical Slices na poziomie Application Layer, ale ze wspólnym Domain Layer.

Kluczowe wnioski

  • Clean Architecture to nie over-engineering - to investment w maintainability dla długoterminowych projektów
  • Dependency Inversion jest kluczowe - Domain defines interfaces, Infrastructure implements
  • Strategic DDD najpierw (Bounded Contexts), tactical patterns potem (Aggregates, VOs)
  • CQRS i Event Sourcing to optional - dodaj gdy potrzebujesz, nie domyślnie
  • Testability pierwsza - jeśli trudno testować, architektura jest zła
  • Możesz łączyć Vertical Slices z Clean Architecture - nie są exclusive

Twój system wymyka się spod kontroli?

Refaktoring legacy kodu, projektowanie Clean Architecture od zera, code review istniejącego systemu. Pracuję z zespołami nad wprowadzeniem DDD i Clean Architecture w .NET i TypeScript. Napiszmy razem architekturę, która przetrwa lata, a nie miesiące.

Powiązane artykuły

Źródła

  1. [1] Robert C. Martin - Clean Architecture: A Craftsman's Guide to Software Structure -Amazon
  2. [2] Eric Evans - Domain-Driven Design: Tackling Complexity in the Heart of Software -Amazon
  3. [3] Vaughn Vernon - Implementing Domain-Driven Design -Amazon
  4. [4] Microsoft - .NET Microservices Architecture Guide -https://learn.microsoft.com/en-us/dotnet/architecture/microservices/
  5. [5] Martin Fowler - Domain-Driven Design -https://martinfowler.com/tags/domain%20driven%20design.html
  6. [6] Jimmy Bogard - Vertical Slice Architecture -https://www.jimmybogard.com/vertical-slice-architecture/
  7. [7] Greg Young - CQRS and Event Sourcing -https://cqrs.wordpress.com/
  8. [8] Alistair Cockburn - Hexagonal Architecture -https://alistair.cockburn.us/hexagonal-architecture/
Clean Architecture i DDD w Praktyce 2025 - Kompletny przewodnik | Wojciechowski.app