MidТеория3 min

Основы Domain-Driven Design

Bounded Context, Ubiquitous Language, Entities, Value Objects, Aggregates, Domain Events и Repositories -- ключевые концепции DDD с примерами на C#

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). Корень гарантирует консистентность всех объектов внутри границ агрегата.

Правила работы с агрегатами:

  1. Внешний код обращается только к Aggregate Root
  2. Изменения внутри агрегата -- только через методы корня
  3. Один агрегат -- одна транзакция
  4. Ссылки между агрегатами -- только по 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 будет полезен.

Проверь себя

Когда лучше использовать Domain Events?

Что такое Aggregate Root?

Что такое Bounded Context?

Почему интерфейс IOrderRepository определяется в Domain Layer, а не в Infrastructure?

Чем Entity отличается от Value Object?