Domain-Driven Design в C#: как не утонуть в сложности

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Domain-Driven Design в C#: как не утонуть в сложности

Когда ваш C# проект перерастает стадию "простое CRUD-приложение", наступает момент истины. Бизнес-логика начинает путаться под ногами, методы сервисов разрастаются до сотен строк, а слово "рефакторинг" вызывает нервную дрожь. Знакомо? Добро пожаловать в мир, где без Domain-Driven Design (DDD) вы рискуете превратить код в болото.

DDD — это не про "ещё один фреймворк". Это философия, которая ставит бизнес-домен в центр вселенной. Сегодня мы разберём, как применить Domain-Driven Design в C# проектах так, чтобы код стал не просто рабочим, а понятным и гибким. Никакой воды — только реальные примеры и практические советы.

Почему DDD спасает C# проекты?

Представьте, что вы пишете систему для управления заказами в интернет-магазине. Если вы просто положите всю логику в контроллеры ASP.NET, через полгода вы не сможете объяснить, почему метод CreateOrder() вызывает CalculateShipping(), который, в свою очередь, лезет в базу за курсами валют. DDD предлагает чёткое разделение: есть доменный слой, где живёт бизнес-логика, и есть инфраструктура, которая занимается техническими деталями.

Ключевая идея DDD — Ubiquitous Language (вездесущий язык). Вы и бизнес-эксперты говорите на одном языке. В коде это выражается в терминах: "Order", "LineItem", "DiscountPolicy", а не "OrderDto", "OrderService" и "Helper". Чем ближе код к речи бизнеса, тем легче его поддерживать.

Стратегическое и тактическое проектирование

DDD делится на две части: стратегическую (как делить систему на модули) и тактическую (как писать код внутри модуля). В C# тактическое проектирование даёт нам четыре мощных строительных блока:

  • Entity — объект с уникальным идентификатором (например, OrderId).
  • Value Object — объект, который определяется своими атрибутами (например, Money или Address).
  • Aggregate Root — корневая сущность, которая управляет группой объектов.
  • Repository — абстракция доступа к данным.

Давайте посмотрим, как это выглядит в коде.

Пример: проектируем заказ (Order)

Начнём с простого: у нас есть заказ, который состоит из позиций. Заказ может быть в статусе "Новый", "Оплачен" или "Отменён".

1. Value Object: Money

Деньги — классический Value Object. Два объекта Money равны, если у них одинаковая сумма и валюта. Они неизменяемы (immutable).

public sealed record Money(decimal Amount, string Currency)
{
    public static Money operator +(Money left, Money right)
    {
        if (left.Currency != right.Currency)
            throw new InvalidOperationException("Currency mismatch");
        return new Money(left.Amount + right.Amount, left.Currency);
    }
}

Обратите внимание на record — в C# 9+ это идеальный способ создать Value Object. Он автоматически даёт Equals, GetHashCode и иммутабельность.

2. Entity: Order и LineItem

У сущностей есть идентификатор. Создадим базовый класс для Entity:

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

    protected Entity(TId id) => Id = id;

    public override bool Equals(object? obj) =>
        obj is Entity<TId> other && Id!.Equals(other.Id);

    public override int GetHashCode() => Id!.GetHashCode();
}

Теперь сам Order и LineItem:

public class Order : Entity<Guid>
{
    private readonly List<LineItem> _items = new();
    public IReadOnlyList<LineItem> Items => _items.AsReadOnly();
    public OrderStatus Status { get; private set; }
    public Money Total { get; private set; }

    public Order(Guid id) : base(id)
    {
        Status = OrderStatus.New;
        Total = new Money(0, "USD");
    }

    public void AddItem(Product product, int quantity)
    {
        if (Status != OrderStatus.New)
            throw new InvalidOperationException("Cannot modify a non-new order.");

        var lineItem = new LineItem(Guid.NewGuid(), product, quantity);
        _items.Add(lineItem);
        RecalculateTotal();
    }

    private void RecalculateTotal()
    {
        Total = _items.Aggregate(new Money(0, "USD"), 
            (current, item) => current + item.Price);
    }
}

public class LineItem : Entity<Guid>
{
    public Product Product { get; }
    public int Quantity { get; }
    public Money Price { get; }

    public LineItem(Guid id, Product product, int quantity) : base(id)
    {
        Product = product;
        Quantity = quantity;
        Price = new Money(product.UnitPrice.Amount * quantity, product.UnitPrice.Currency);
    }
}

Обратите внимание: бизнес-правило "нельзя менять заказ после оплаты" живёт прямо внутри метода AddItem. Это и есть суть DDD — логика не размазана по сервисам, а инкапсулирована в домене.

3. Aggregate Root: Order как корень

В нашем примере Order — это Aggregate Root. Он управляет LineItem. Никто не может создать LineItem сам по себе — только через Order.AddItem(). Это гарантирует целостность данных.

4. Repository: абстракция для доступа к данным

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id);
    Task AddAsync(Order order);
    Task UpdateAsync(Order order);
}

Реализация может быть на Entity Framework Core, Dapper или даже на файлах — домену всё равно. Главное: репозиторий работает только с Aggregate Roots.

Как не переборщить с DDD?

DDD — это мощный инструмент, но он не для всех проектов. Вот когда он реально нужен:

  • Сложная бизнес-логика с множеством правил и состояний.
  • Высокая вероятность изменения требований.
  • Команда разработчиков готова к дополнительной сложности.

А вот когда DDD будет лишним:

  • Простое CRUD-приложение (например, блог или форма обратной связи).
  • Жёсткие сроки и нет времени на моделирование.
  • Команда не понимает принципов DDD — лучше начать с малого.

Типичные ошибки в C# проектах с DDD

Даже опытные разработчики иногда спотыкаются. Вот три грабли, которые я видел чаще всего:

  1. Анемичная модель. Вы объявили класс, но вся логика в сервисах. Это не DDD, это просто набор геттеров и сеттеров.
  2. Гигантские агрегаты. Если ваш Order тянет за собой все заказы клиента за 10 лет — вы что-то делаете не так

Блоги

Рекомендаций по книгам