Przejdź do treści
Backend

Clean Architecture i DDD w praktyce – przewodnik

Opublikowano:
·6 min czytania·Autor: MDS Software Solutions Group

Clean Architecture DDD

backend

Clean Architecture i DDD w praktyce

Budowanie skalowalnych, testowalnych i łatwych w utrzymaniu aplikacji to jedno z największych wyzwań współczesnej inżynierii oprogramowania. Dwa podejścia, które od lat wyznaczają złoty standard w projektowaniu systemów, to Clean Architecture Roberta C. Martina oraz Domain-Driven Design (DDD) Erica Evansa. W tym artykule pokażemy, jak te koncepcje współgrają ze sobą, jak wdrożyć je w praktyce z użyciem .NET i TypeScript, oraz jak porównują się z architekturą heksagonalną.

Czym jest Clean Architecture?#

Clean Architecture to wzorzec architektoniczny zaproponowany przez Roberta C. Martina (Uncle Bob) w 2012 roku. Jego głównym celem jest stworzenie systemu, w którym logika biznesowa jest całkowicie niezależna od frameworków, baz danych, interfejsów użytkownika i wszelkich zewnętrznych zależności.

Kluczową ideą jest podział systemu na koncentryczne warstwy, gdzie zależności zawsze wskazują do wewnątrz - od detali implementacyjnych do reguł biznesowych.

Warstwy Clean Architecture#

1. Entities (Encje domenowe)

Encje to rdzeń systemu. Zawierają najważniejsze reguły biznesowe, które byłyby prawdziwe niezależnie od tego, czy aplikacja istnieje. Encje nie wiedzą nic o bazach danych, frameworkach ani interfejsach użytkownika.

// .NET - Encja domenowa
public class Order
{
    public Guid Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public Money TotalAmount { get; private set; }
    public OrderStatus Status { get; private set; }
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    public Order(CustomerId customerId)
    {
        Id = Guid.NewGuid();
        CustomerId = customerId;
        Status = OrderStatus.Draft;
        TotalAmount = Money.Zero("PLN");
    }

    public void AddLine(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Nie można modyfikować zatwierdzonego zamówienia.");

        if (quantity <= 0)
            throw new DomainException("Ilość musi być większa od zera.");

        var line = new OrderLine(product.Id, product.Price, quantity);
        _lines.Add(line);
        RecalculateTotal();
    }

    public void Confirm()
    {
        if (!_lines.Any())
            throw new DomainException("Nie można zatwierdzić pustego zamówienia.");

        Status = OrderStatus.Confirmed;
        _domainEvents.Add(new OrderConfirmedEvent(Id, CustomerId, TotalAmount));
    }

    private void RecalculateTotal()
    {
        TotalAmount = _lines.Aggregate(
            Money.Zero("PLN"),
            (sum, line) => sum.Add(line.SubTotal));
    }
}

2. Use Cases (Przypadki użycia)

Warstwa Use Cases zawiera logikę specyficzną dla aplikacji. Orchestruje przepływ danych między encjami i kieruje je do odpowiednich portów wyjściowych. Ta warstwa nie wie, skąd dane pochodzą ani dokąd trafiają.

// .NET - Use Case (Application Service)
public class ConfirmOrderUseCase : IRequestHandler<ConfirmOrderCommand, OrderDto>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IPaymentGateway _paymentGateway;
    private readonly IEventPublisher _eventPublisher;
    private readonly IUnitOfWork _unitOfWork;

    public ConfirmOrderUseCase(
        IOrderRepository orderRepository,
        IPaymentGateway paymentGateway,
        IEventPublisher eventPublisher,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _paymentGateway = paymentGateway;
        _eventPublisher = eventPublisher;
        _unitOfWork = unitOfWork;
    }

    public async Task<OrderDto> Handle(
        ConfirmOrderCommand command, CancellationToken ct)
    {
        var order = await _orderRepository.GetByIdAsync(command.OrderId, ct)
            ?? throw new NotFoundException($"Zamówienie {command.OrderId} nie istnieje.");

        order.Confirm();

        await _paymentGateway.InitiatePaymentAsync(
            order.Id, order.TotalAmount, ct);

        await _unitOfWork.SaveChangesAsync(ct);

        foreach (var domainEvent in order.DomainEvents)
            await _eventPublisher.PublishAsync(domainEvent, ct);

        return OrderDto.FromDomain(order);
    }
}

3. Interface Adapters (Adaptery interfejsów)

Adaptery konwertują dane z formatu wygodnego dla Use Cases na format wymagany przez zewnętrzne systemy (bazy danych, API, UI) i odwrotnie. Tu znajdują się kontrolery, prezentatory, repozytoria i mapery.

// TypeScript - Adapter (Controller)
@Controller('/api/orders')
export class OrderController {
  constructor(
    private readonly confirmOrder: ConfirmOrderUseCase,
    private readonly getOrderDetails: GetOrderDetailsQuery,
  ) {}

  @Post(':id/confirm')
  @HttpCode(200)
  async confirm(@Param('id') id: string): Promise<OrderResponse> {
    const command = new ConfirmOrderCommand(id);
    const result = await this.confirmOrder.execute(command);

    return OrderResponse.fromDto(result);
  }

  @Get(':id')
  async getDetails(@Param('id') id: string): Promise<OrderResponse> {
    const query = new GetOrderDetailsQuery(id);
    const result = await this.getOrderDetails.execute(query);

    if (!result) {
      throw new NotFoundException(`Order ${id} not found`);
    }

    return OrderResponse.fromDto(result);
  }
}

4. Frameworks & Drivers (Frameworki i sterowniki)

Najbardziej zewnętrzna warstwa zawiera szczegóły implementacyjne: frameworki webowe, bazy danych, systemy kolejkowe, zewnętrzne API. Ta warstwa jest najbardziej podatna na zmiany i dlatego powinna być jak najcieńsza.

// .NET - Implementacja repozytorium (Infrastructure)
public class EfOrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public EfOrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        return await _context.Orders
            .Include(o => o.Lines)
            .FirstOrDefaultAsync(o => o.Id == id, ct);
    }

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

    public void Update(Order order)
    {
        _context.Orders.Update(order);
    }
}

Dependency Rule - Reguła zależności#

Dependency Rule to najważniejsza zasada Clean Architecture. Mówi ona, że zależności w kodzie źródłowym mogą wskazywać wyłącznie do wewnątrz, w kierunku warstw wyższego poziomu. Warstwa wewnętrzna nie może wiedzieć absolutnie nic o warstwach zewnętrznych.

W praktyce oznacza to:

  • Entities nie zależą od niczego
  • Use Cases zależą tylko od Entities i definiują interfejsy portów
  • Interface Adapters zależą od Use Cases i implementują porty
  • Frameworks zależą od Interface Adapters

Inwersja zależności (Dependency Inversion Principle) jest kluczowym mechanizmem umożliwiającym przestrzeganie tej reguły. Warstwa wewnętrzna definiuje interfejs (port), a warstwa zewnętrzna dostarcza implementację (adapter).

// Port (definiowany w warstwie Use Cases)
export interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

// Adapter (implementowany w warstwie Infrastructure)
export class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async findById(id: OrderId): Promise<Order | null> {
    const data = await this.prisma.order.findUnique({
      where: { id: id.value },
      include: { lines: true },
    });
    return data ? OrderMapper.toDomain(data) : null;
  }

  async save(order: Order): Promise<void> {
    const data = OrderMapper.toPersistence(order);
    await this.prisma.order.upsert({
      where: { id: data.id },
      create: data,
      update: data,
    });
  }
}

Domain-Driven Design - budulce domeny#

Domain-Driven Design to podejście do projektowania oprogramowania, które stawia domenę biznesową w centrum procesu tworzenia systemu. DDD wprowadza zestaw taktycznych wzorców (building blocks), które pomagają modelować złożoność biznesową.

Value Objects (Obiekty wartości)#

Value Objects to obiekty definiowane przez swoje atrybuty, a nie tożsamość. Dwa Value Objects z tymi samymi atrybutami są sobie równe. Są niezmienne i enkapsulują reguły walidacji.

// .NET - Value Object
public record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new DomainException("Kwota nie może być ujemna.");
        if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
            throw new DomainException("Nieprawidłowy kod waluty.");

        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }

    public static Money Zero(string currency) => new(0, currency);

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new DomainException(
                $"Nie można dodać {Currency} i {other.Currency}.");
        return new Money(Amount + other.Amount, Currency);
    }

    public Money Multiply(int factor) => new(Amount * factor, Currency);
}

public record Address(
    string Street,
    string City,
    string PostalCode,
    string Country)
{
    public string FullAddress => $"{Street}, {PostalCode} {City}, {Country}";
}
// TypeScript - Value Object
export class Money {
  private constructor(
    public readonly amount: number,
    public readonly currency: string,
  ) {}

  static create(amount: number, currency: string): Money {
    if (amount < 0) throw new DomainError('Amount cannot be negative');
    if (!/^[A-Z]{3}$/.test(currency))
      throw new DomainError('Invalid currency code');
    return new Money(amount, currency);
  }

  static zero(currency: string): Money {
    return new Money(0, currency);
  }

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

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

Aggregates (Agregaty)#

Agregat to klaster powiązanych obiektów domenowych traktowany jako jedna jednostka spójności. Każdy agregat ma korzeń (Aggregate Root), który jest jedynym punktem dostępu do modyfikacji stanu agregatu. Agregaty definiują granice transakcji.

// .NET - Aggregate Root
public class ShoppingCart : AggregateRoot
{
    public CustomerId CustomerId { get; private set; }
    private readonly List<CartItem> _items = new();
    public IReadOnlyCollection<CartItem> Items => _items.AsReadOnly();
    public Money Total => CalculateTotal();

    public ShoppingCart(CustomerId customerId)
    {
        Id = Guid.NewGuid();
        CustomerId = customerId;
    }

    public void AddItem(ProductId productId, Money price, int quantity)
    {
        var existingItem = _items.FirstOrDefault(
            i => i.ProductId == productId);

        if (existingItem != null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(new CartItem(productId, price, quantity));
        }

        AddDomainEvent(new ItemAddedToCartEvent(
            Id, productId, quantity));
    }

    public void RemoveItem(ProductId productId)
    {
        var item = _items.FirstOrDefault(
            i => i.ProductId == productId)
            ?? throw new DomainException("Produkt nie jest w koszyku.");

        _items.Remove(item);
    }

    public Order Checkout(ShippingAddress address)
    {
        if (!_items.Any())
            throw new DomainException("Koszyk jest pusty.");

        var order = new Order(CustomerId, address);
        foreach (var item in _items)
            order.AddLine(item.ProductId, item.Price, item.Quantity);

        _items.Clear();
        AddDomainEvent(new CartCheckedOutEvent(Id, order.Id));

        return order;
    }

    private Money CalculateTotal()
    {
        return _items.Aggregate(
            Money.Zero("PLN"),
            (sum, item) => sum.Add(item.SubTotal));
    }
}

Repositories (Repozytoria)#

Repozytoria zapewniają abstrakcję dostępu do danych dla agregatów. Definiują interfejs kolekcji, dzięki czemu domena nie musi wiedzieć, jak dane są przechowywane. Jedno repozytorium odpowiada jednemu agregatowi.

// .NET - Interfejs repozytorium (w warstwie domeny)
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> GetByCustomerIdAsync(
        CustomerId customerId, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    void Update(Order order);
    Task<bool> ExistsAsync(Guid id, CancellationToken ct = default);
}

Domain Events (Zdarzenia domenowe)#

Zdarzenia domenowe reprezentują istotne fakty, które zaszły w domenie. Pozwalają na luźne powiązanie między agregatami i umożliwiają reakcję na zmiany stanu bez bezpośrednich zależności.

// .NET - Domain Event
public record OrderConfirmedEvent(
    Guid OrderId,
    CustomerId CustomerId,
    Money TotalAmount) : IDomainEvent
{
    public DateTime OccurredAt { get; } = DateTime.UtcNow;
}

// Handler zdarzenia domenowego
public class SendOrderConfirmationEmail
    : INotificationHandler<OrderConfirmedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ICustomerRepository _customerRepository;

    public SendOrderConfirmationEmail(
        IEmailService emailService,
        ICustomerRepository customerRepository)
    {
        _emailService = emailService;
        _customerRepository = customerRepository;
    }

    public async Task Handle(
        OrderConfirmedEvent notification, CancellationToken ct)
    {
        var customer = await _customerRepository
            .GetByIdAsync(notification.CustomerId, ct);

        await _emailService.SendAsync(
            customer!.Email,
            "Potwierdzenie zamówienia",
            $"Zamówienie #{notification.OrderId} na kwotę " +
            $"{notification.TotalAmount.Amount} {notification.TotalAmount.Currency} " +
            "zostało potwierdzone.",
            ct);
    }
}

Domain Services (Usługi domenowe)#

Domain Services zawierają logikę biznesową, która nie pasuje naturalnie do żadnej encji ani Value Object. Operują na wielu agregatach lub wymagają dostępu do zewnętrznych zasobów przez porty.

// TypeScript - Domain Service
export class PricingService {
  constructor(
    private readonly discountPolicy: DiscountPolicy,
    private readonly taxCalculator: TaxCalculator,
  ) {}

  calculateFinalPrice(
    items: OrderItem[],
    customer: Customer,
  ): PriceBreakdown {
    const subtotal = items.reduce(
      (sum, item) => sum.add(item.price.multiply(item.quantity)),
      Money.zero('PLN'),
    );

    const discount = this.discountPolicy.calculateDiscount(
      subtotal,
      customer.loyaltyTier,
    );

    const afterDiscount = subtotal.subtract(discount);
    const tax = this.taxCalculator.calculate(afterDiscount, 'PL');

    return new PriceBreakdown(subtotal, discount, tax, afterDiscount.add(tax));
  }
}

Bounded Contexts i Ubiquitous Language#

Bounded Contexts (Konteksty ograniczone)#

Bounded Context to fundamentalne pojęcie strategicznego DDD. Definiuje granice, w ramach których dany model domenowy jest spójny i ma jednoznaczne znaczenie. Różne konteksty mogą używać tych samych terminów, ale z różnym znaczeniem.

Przykładowo w systemie e-commerce:

  • Sales Context - Product to obiekt z ceną, opisem i dostępnością
  • Warehouse Context - Product to obiekt z lokalizacją w magazynie, wagą i wymiarami
  • Shipping Context - Product to pozycja w przesyłce z adresem dostawy

Każdy kontekst ma własny model, własne repozytorium i własne reguły biznesowe. Konteksty komunikują się ze sobą przez zdarzenia integracyjne (Integration Events), a nie przez bezpośrednie odwołania.

┌─────────────────────┐     ┌─────────────────────┐
│   Sales Context     │     │  Warehouse Context   │
│                     │     │                      │
│  Order              │ ──► │  StockReservation    │
│  Product            │     │  Product             │
│  Customer           │     │  Location            │
│  Payment            │     │  InventoryItem       │
│                     │     │                      │
│  OrderConfirmedEvent│     │  StockReservedEvent  │
└─────────────────────┘     └──────────────────────┘
         │                            │
         ▼                            ▼
┌──────────────────────────────────────────────────┐
│              Message Broker (RabbitMQ / Kafka)   │
└──────────────────────────────────────────────────┘
         │                            │
         ▼                            ▼
┌─────────────────────┐     ┌─────────────────────┐
│  Shipping Context   │     │  Billing Context     │
│                     │     │                      │
│  Shipment           │     │  Invoice             │
│  DeliveryAddress    │     │  Payment             │
│  Carrier            │     │  TaxCalculation      │
└─────────────────────┘     └─────────────────────┘

Ubiquitous Language (Język wszechobecny)#

Ubiquitous Language to wspólny język używany przez zespół deweloperski i ekspertów domenowych. Ten sam słownik powinien być obecny w kodzie, dokumentacji, rozmowach i testach. Eliminuje to nieporozumienia i zapewnia, że model w kodzie wiernie odzwierciedla rzeczywistość biznesową.

Przykład stosowania Ubiquitous Language:

  • Zamiast setStatus("confirmed") piszemy order.Confirm()
  • Zamiast user.type = "premium" piszemy customer.UpgradeToLoyaltyTier(LoyaltyTier.Gold)
  • Zamiast item.count -= 1 piszemy inventory.ReserveStock(productId, quantity)

CQRS - Command Query Responsibility Segregation#

CQRS to wzorzec polegający na rozdzieleniu operacji odczytu (Query) od operacji zapisu (Command). Dzięki temu modele odczytu i zapisu mogą być optymalizowane niezależnie.

// .NET - CQRS z MediatR

// Command - operacja zapisu
public record CreateOrderCommand(
    Guid CustomerId,
    List<OrderLineDto> Lines) : IRequest<Guid>;

public class CreateOrderCommandHandler
    : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(
        IOrderRepository orderRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(
        CreateOrderCommand command, CancellationToken ct)
    {
        var order = new Order(new CustomerId(command.CustomerId));

        foreach (var line in command.Lines)
        {
            order.AddLine(
                new Product(line.ProductId, new Money(line.Price, "PLN")),
                line.Quantity);
        }

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

        return order.Id;
    }
}

// Query - operacja odczytu (z osobnego modelu)
public record GetOrderSummaryQuery(Guid OrderId) : IRequest<OrderSummaryDto?>;

public class GetOrderSummaryQueryHandler
    : IRequestHandler<GetOrderSummaryQuery, OrderSummaryDto?>
{
    private readonly IReadDbContext _readDb;

    public GetOrderSummaryQueryHandler(IReadDbContext readDb)
    {
        _readDb = readDb;
    }

    public async Task<OrderSummaryDto?> Handle(
        GetOrderSummaryQuery query, CancellationToken ct)
    {
        return await _readDb.OrderSummaries
            .Where(o => o.Id == query.OrderId)
            .Select(o => new OrderSummaryDto
            {
                Id = o.Id,
                CustomerName = o.CustomerName,
                TotalAmount = o.TotalAmount,
                ItemCount = o.ItemCount,
                Status = o.Status,
                CreatedAt = o.CreatedAt
            })
            .FirstOrDefaultAsync(ct);
    }
}

Event Sourcing#

Event Sourcing to wzorzec, w którym stan agregatu nie jest przechowywany bezpośrednio, lecz odtwarzany z sekwencji zdarzeń. Każda zmiana stanu jest zapisywana jako niezmienny fakt (event). Daje to pełną historię zmian, możliwość odtworzenia stanu na dowolny moment w czasie oraz naturalną integrację z CQRS.

// TypeScript - Event Sourcing
export abstract class EventSourcedAggregate {
  private uncommittedEvents: DomainEvent[] = [];
  protected version = 0;

  protected apply(event: DomainEvent): void {
    this.when(event);
    this.version++;
    this.uncommittedEvents.push(event);
  }

  protected abstract when(event: DomainEvent): void;

  loadFromHistory(events: DomainEvent[]): void {
    for (const event of events) {
      this.when(event);
      this.version++;
    }
  }

  getUncommittedEvents(): DomainEvent[] {
    return [...this.uncommittedEvents];
  }

  clearUncommittedEvents(): void {
    this.uncommittedEvents = [];
  }
}

export class BankAccount extends EventSourcedAggregate {
  private _id!: string;
  private _balance!: Money;
  private _status!: AccountStatus;

  static open(id: string, owner: string, initialDeposit: Money): BankAccount {
    const account = new BankAccount();
    account.apply(new AccountOpenedEvent(id, owner, initialDeposit));
    return account;
  }

  deposit(amount: Money): void {
    if (this._status !== AccountStatus.Active)
      throw new DomainError('Account is not active');
    this.apply(new MoneyDepositedEvent(this._id, amount));
  }

  withdraw(amount: Money): void {
    if (this._status !== AccountStatus.Active)
      throw new DomainError('Account is not active');
    if (this._balance.isLessThan(amount))
      throw new DomainError('Insufficient funds');
    this.apply(new MoneyWithdrawnEvent(this._id, amount));
  }

  protected when(event: DomainEvent): void {
    if (event instanceof AccountOpenedEvent) {
      this._id = event.accountId;
      this._balance = event.initialDeposit;
      this._status = AccountStatus.Active;
    } else if (event instanceof MoneyDepositedEvent) {
      this._balance = this._balance.add(event.amount);
    } else if (event instanceof MoneyWithdrawnEvent) {
      this._balance = this._balance.subtract(event.amount);
    }
  }
}

// Event Store Repository
export class EventStoreRepository {
  constructor(private readonly eventStore: EventStoreClient) {}

  async save(aggregate: EventSourcedAggregate, streamId: string): Promise<void> {
    const events = aggregate.getUncommittedEvents();
    const eventData = events.map((e) => ({
      type: e.constructor.name,
      data: JSON.stringify(e),
      metadata: JSON.stringify({ timestamp: new Date().toISOString() }),
    }));

    await this.eventStore.appendToStream(streamId, eventData);
    aggregate.clearUncommittedEvents();
  }

  async load<T extends EventSourcedAggregate>(
    streamId: string,
    factory: () => T,
  ): Promise<T> {
    const events = await this.eventStore.readStream(streamId);
    const aggregate = factory();
    aggregate.loadFromHistory(events);
    return aggregate;
  }
}

Architektura heksagonalna a Clean Architecture#

Architektura heksagonalna (Ports & Adapters), zaproponowana przez Alistaira Cockburna, jest blisko spokrewniona z Clean Architecture. Obie opierają się na izolacji domeny od szczegółów implementacyjnych, ale używają nieco innej terminologii.

| Aspekt | Clean Architecture | Architektura heksagonalna | |--------|-------------------|--------------------------| | Struktura | Koncentryczne warstwy | Heksagon z portami | | Punkt centralny | Entities | Domain Model | | Interfejsy | Use Case boundaries | Porty (wejściowe/wyjściowe) | | Implementacje | Interface Adapters | Adaptery (primary/secondary) | | Zależności | Dependency Rule (do wewnątrz) | Dependency Inversion | | Warstwy | 4 wyraźne warstwy | 3 obszary (domena, porty, adaptery) |

W praktyce architektura heksagonalna kładzie większy nacisk na porty wejściowe (driving) i wyjściowe (driven), podczas gdy Clean Architecture precyzyjniej definiuje granice między warstwami. Obie doskonale współgrają z DDD.

Architektura heksagonalna:

        ┌──────────────────────┐
        │   Primary Adapters   │
        │  (Controllers, CLI)  │
        │         │            │
        │    ┌────▼────┐       │
        │    │  Input   │       │
        │    │  Ports   │       │
        │    │    │     │       │
        │    │ ┌──▼───┐ │       │
        │    │ │Domain│ │       │
        │    │ │Model │ │       │
        │    │ └──┬───┘ │       │
        │    │    │     │       │
        │    │ Output  │       │
        │    │  Ports   │       │
        │    └────┬────┘       │
        │         │            │
        │  Secondary Adapters  │
        │  (DB, APIs, Queue)   │
        └──────────────────────┘

Struktura projektu w praktyce#

Dobrze zorganizowany projekt łączący Clean Architecture z DDD powinien mieć czytelną strukturę katalogów odzwierciedlającą warstwy i konteksty:

src/
├── Domain/                          # Warstwa encji
│   ├── Orders/
│   │   ├── Order.cs                 # Aggregate Root
│   │   ├── OrderLine.cs             # Entity
│   │   ├── OrderStatus.cs           # Enum
│   │   ├── IOrderRepository.cs      # Port repozytorium
│   │   └── Events/
│   │       ├── OrderConfirmedEvent.cs
│   │       └── OrderCancelledEvent.cs
│   ├── Customers/
│   │   ├── Customer.cs
│   │   └── ICustomerRepository.cs
│   └── SharedKernel/
│       ├── Money.cs                 # Value Object
│       ├── AggregateRoot.cs         # Bazowa klasa
│       └── IDomainEvent.cs
├── Application/                     # Warstwa Use Cases
│   ├── Orders/
│   │   ├── Commands/
│   │   │   ├── CreateOrderCommand.cs
│   │   │   └── ConfirmOrderCommand.cs
│   │   ├── Queries/
│   │   │   ├── GetOrderQuery.cs
│   │   │   └── GetOrdersByCustomerQuery.cs
│   │   └── EventHandlers/
│   │       └── OrderConfirmedHandler.cs
│   └── Common/
│       ├── IUnitOfWork.cs
│       └── IEventPublisher.cs
├── Infrastructure/                  # Warstwa Frameworks & Drivers
│   ├── Persistence/
│   │   ├── AppDbContext.cs
│   │   ├── EfOrderRepository.cs
│   │   └── Configurations/
│   │       └── OrderConfiguration.cs
│   ├── Messaging/
│   │   └── RabbitMqEventPublisher.cs
│   └── ExternalServices/
│       └── StripePaymentGateway.cs
└── WebApi/                          # Warstwa Interface Adapters
    ├── Controllers/
    │   └── OrdersController.cs
    ├── Middleware/
    │   └── ExceptionHandlingMiddleware.cs
    └── Program.cs

Najczęstsze błędy i jak ich unikać#

  1. Anemiczny model domenowy - encje zawierające tylko gettery i settery bez logiki biznesowej. Rozwiązanie: przenieś logikę do encji, ukryj settery, wymuszaj niezmienniki.

  2. Za duże agregaty - agregaty zawierające zbyt wiele encji, powodujące problemy z wydajnością i współbieżnością. Rozwiązanie: projektuj małe agregaty, odwołuj się między nimi przez identyfikatory.

  3. Wyciekanie domeny - logika biznesowa w kontrolerach lub serwisach infrastrukturalnych. Rozwiązanie: stosuj ściśle Dependency Rule, przenoś logikę do warstwy domeny.

  4. Nadużywanie wzorców - stosowanie DDD i Clean Architecture w prostych aplikacjach CRUD. Rozwiązanie: oceniaj złożoność domeny przed wyborem architektury.

  5. Ignorowanie Bounded Contexts - jeden wielki model dla całego systemu. Rozwiązanie: identyfikuj granice kontekstów z ekspertami domenowymi, separuj modele.

Podsumowanie#

Clean Architecture i Domain-Driven Design to potężne narzędzia w arsenale każdego architekta oprogramowania. Ich połączenie pozwala tworzyć systemy, które są:

  • Testowalne - logika biznesowa jest izolowana od infrastruktury
  • Elastyczne - łatwa wymiana frameworków, baz danych i zewnętrznych serwisów
  • Zrozumiałe - kod odzwierciedla język biznesu dzięki Ubiquitous Language
  • Skalowalne - Bounded Contexts i CQRS umożliwiają niezależne skalowanie komponentów
  • Audytowalne - Event Sourcing zapewnia pełną historię zmian

Kluczem do sukcesu jest pragmatyczne podejście - stosuj te wzorce tam, gdzie złożoność domeny to uzasadnia, i nie bój się prostszych rozwiązań dla mniej skomplikowanych części systemu.


Potrzebujesz pomocy z architekturą systemu?#

W MDS Software Solutions Group projektujemy i wdrażamy systemy oparte na Clean Architecture i DDD. Nasze doświadczenie obejmuje budowę skalowalnych aplikacji enterprise w .NET, TypeScript i Java. Niezależnie od tego, czy rozpoczynasz nowy projekt, czy modernizujesz istniejący system - pomożemy Ci wybrać odpowiednią architekturę i wdrożyć ją w praktyce.

Skontaktuj się z nami i porozmawiajmy o Twoim projekcie.

Autor
MDS Software Solutions Group

Zespół ekspertów programistycznych specjalizujących się w nowoczesnych technologiach webowych.

Clean Architecture i DDD w praktyce – przewodnik | MDS Software Solutions Group | MDS Software Solutions Group