Clean Architecture i DDD w praktyce – przewodnik
Clean Architecture DDD
backendClean 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 -
Productto obiekt z ceną, opisem i dostępnością - Warehouse Context -
Productto obiekt z lokalizacją w magazynie, wagą i wymiarami - Shipping Context -
Productto 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")piszemyorder.Confirm() - Zamiast
user.type = "premium"piszemycustomer.UpgradeToLoyaltyTier(LoyaltyTier.Gold) - Zamiast
item.count -= 1piszemyinventory.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ć#
-
Anemiczny model domenowy - encje zawierające tylko gettery i settery bez logiki biznesowej. Rozwiązanie: przenieś logikę do encji, ukryj settery, wymuszaj niezmienniki.
-
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.
-
Wyciekanie domeny - logika biznesowa w kontrolerach lub serwisach infrastrukturalnych. Rozwiązanie: stosuj ściśle Dependency Rule, przenoś logikę do warstwy domeny.
-
Nadużywanie wzorców - stosowanie DDD i Clean Architecture w prostych aplikacjach CRUD. Rozwiązanie: oceniaj złożoność domeny przed wyborem architektury.
-
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.
Zespół ekspertów programistycznych specjalizujących się w nowoczesnych technologiach webowych.