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
Даже опытные разработчики иногда спотыкаются. Вот три грабли, которые я видел чаще всего:
- Анемичная модель. Вы объявили класс, но вся логика в сервисах. Это не DDD, это просто набор геттеров и сеттеров.
- Гигантские агрегаты. Если ваш
Orderтянет за собой все заказы клиента за 10 лет — вы что-то делаете не так