Domain-Driven Design (проектирование на основе предметной области) -- подход к разработке, при котором бизнес-логика становится центром архитектуры. Код моделирует реальные бизнес-процессы, а не технические абстракции.
Стратегические паттерны
Bounded Context (Ограниченный контекст)
Bounded Context -- это чёткая граница, внутри которой термины имеют однозначное значение. Слово «заказ» может означать разные вещи в разных отделах компании: для отдела продаж это список товаров с ценами, для склада -- набор позиций для комплектации, для бухгалтерии -- финансовый документ.
В нашем проекте выделяются следующие контексты:
- Order Context -- управление заказами (основной контекст)
- Catalog Context -- каталог товаров
- Payment Context -- обработка платежей
- Shipping Context -- доставка
Каждый контекст может быть реализован как отдельный модуль или микросервис. Между контекстами используются ссылки по идентификаторам, а не прямые связи через объекты.
Ubiquitous Language (Единый язык)
Разработчики и бизнес-специалисты должны использовать одни и те же термины. Это устраняет разночтения и ошибки перевода между «языком бизнеса» и «языком кода».
| Бизнес-термин | Класс в коде |
|---|---|
| Заказ | Order |
| Позиция заказа | OrderItem |
| Адрес доставки | ShippingAddress |
| Статус заказа | OrderStatus |
Если в коде появляется класс OrderLineItem, но бизнес говорит «позиция заказа» -- это нарушение единого языка. Нужно выбрать один термин и использовать его везде.
Тактические паттерны
Entity (Сущность)
Сущность -- это объект с уникальной идентичностью, которая сохраняется на протяжении всего жизненного цикла. Два заказа с одинаковым содержимым, но разными идентификаторами -- это разные заказы.
public abstract class Entity
{
public Guid Id { get; protected set; }
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents =>
_domainEvents.AsReadOnly();
protected void AddDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
public override bool Equals(object? obj)
{
if (obj is not Entity other) return false;
if (ReferenceEquals(this, other)) return true;
if (GetType() != other.GetType()) return false;
return Id == other.Id;
}
public override int GetHashCode() => Id.GetHashCode();
}
Обратите внимание: сравнение сущностей происходит по Id, а не по значениям полей. Даже если все свойства двух объектов Order совпадают, они не равны, если у них разные идентификаторы.
Value Object (Объект-значение)
Объект-значение определяется только своими атрибутами и не имеет идентичности. Два объекта Money(100, "RUB") -- это одно и то же, независимо от того, где они находятся в памяти. Value Object всегда неизменяемый (immutable).
public class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new DomainException("Amount cannot be negative");
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new DomainException("Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
public Money Multiply(int quantity)
{
return new Money(Amount * quantity, Currency);
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
}
Метод Add не изменяет текущий объект, а создаёт новый. Это гарантирует, что Value Object не может быть случайно изменён из другого места кода.
Типичные примеры Value Objects: деньги, адрес, email, координаты. Если вопрос «это тот же самый объект или другой?» не имеет смысла -- перед вами Value Object.
Aggregate (Агрегат)
Агрегат -- кластер связанных сущностей и объектов-значений с единым корнем (Aggregate Root). Корень гарантирует консистентность всех объектов внутри границ агрегата.
Правила работы с агрегатами:
- Внешний код обращается только к Aggregate Root
- Изменения внутри агрегата -- только через методы корня
- Один агрегат -- одна транзакция
- Ссылки между агрегатами -- только по ID
public class Order : Entity, IAggregateRoot
{
private readonly List<OrderItem> _items = new();
public string CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public ShippingAddress ShippingAddress { get; private set; }
public Money TotalAmount { get; private set; }
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
private Order() { }
public static Order Create(string customerId, ShippingAddress address)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
ShippingAddress = address,
Status = OrderStatus.Draft,
TotalAmount = Money.Zero(),
CreatedAt = DateTime.UtcNow
};
order.AddDomainEvent(new OrderCreatedEvent(order.Id, customerId));
return order;
}
public void AddItem(string productId, string productName,
Money unitPrice, int quantity)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Can only add items to draft orders");
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
if (existingItem != null)
existingItem.IncreaseQuantity(quantity);
else
_items.Add(new OrderItem(productId, productName, unitPrice, quantity));
RecalculateTotal();
}
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be confirmed");
if (!_items.Any())
throw new DomainException("Cannot confirm an empty order");
Status = OrderStatus.Confirmed;
AddDomainEvent(new OrderConfirmedEvent(Id, TotalAmount));
}
}
Order -- это Aggregate Root. Нельзя создать OrderItem отдельно от заказа: конструктор OrderItem имеет модификатор internal, и добавление позиций идёт только через order.AddItem(). Это защищает инварианты агрегата.
Domain Event (Доменное событие)
Доменное событие фиксирует факт, который произошёл в домене и может быть интересен другим частям системы.
public interface IDomainEvent
{
Guid EventId { get; }
DateTime OccurredOn { get; }
}
public record OrderCreatedEvent(Guid OrderId, string CustomerId)
: IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
public record OrderConfirmedEvent(Guid OrderId, Money TotalAmount)
: IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
Событие OrderConfirmedEvent может быть обработано разными обработчиками: один отправит email клиенту, другой зарезервирует товар на складе, третий обновит статистику. При этом класс Order ничего не знает об этих обработчиках -- он просто публикует событие.
Repository (Репозиторий)
Репозиторий предоставляет абстракцию для доступа к агрегатам. Интерфейс определяется в Domain Layer, а реализация -- в Infrastructure.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetByCustomerIdAsync(
string customerId, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
void Update(Order order);
}
public interface IUnitOfWork : IDisposable
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
Репозиторий работает только с Aggregate Root. Не существует отдельного IOrderItemRepository -- для изменения позиций нужно получить заказ, вызвать его бизнес-метод и сохранить.
Когда применять DDD
DDD оправдывает себя в проектах со сложной бизнес-логикой, где правила часто меняются. Для простых CRUD-приложений DDD может быть избыточным. Ключевой критерий: если бизнес-правила сложнее, чем «записать данные в таблицу и прочитать обратно» -- DDD будет полезен.