День 2309. #ЗаметкиНаПолях
Уходим от Анемичных Моделей. Пример DDD-Рефакторинга. Начало
Если вы когда-либо работали с устаревшей кодовой базой C#, вы знаете боль анемичной модели домена. Вы открывали какой-нибудь OrderService и думали: «Этот класс делает всё: логика ценообразования, правила скидок, проверка остатков, запись в БД и т.п.» Это работает… какое-то время. Но новые функции превращаются в регрессионную рулетку, а тестовое покрытие резко падает, потому что доменная логика погребена под инфраструктурной.
Это классический симптом анемичной модели домена, где сущности — это всего лишь держатели данных, а вся логика находится в другом месте. Это затрудняет понимание системы, и каждое изменение превращается в угадайку.
В этой серии мы:
- Изучим типичную анемичную реализацию.
- Определим скрытые бизнес-правила, которые делают её хрупкой.
- Выполним рефакторинг в сторону агрегата с богатым поведением.
- Выделим конкретные выгоды, чтобы вы могли обосновать изменение для членов команды.
Начало: Божественный Класс Сервиса
Ниже приведён (к сожалению, распространённый пример) OrderService. Помимо расчёта итоговых сумм он также:
- применяет скидку VIP 5%,
- выбрасывает исключение, если какой-то товар отсутствует на складе,
- отклоняет заказы, которые превышают кредитный лимит клиента.
// OrderService.cs
public void PlaceOrder(
Guid customerId,
IEnumerable<OrderItemDto> items)
{
var customer = _db.Customers.Find(customerId);
if (customer is null)
throw new ArgumentException("Клиент не найден");
var order = new Order { CustomerId = customerId };
foreach (var dto in items)
{
var inventory = _invService
.GetStock(dto.ProductId);
if (inventory < dto.Quantity)
throw new InvalidOperationException("Товара недостаточно");
var price = _pricingService
.GetPrice(dto.ProductId);
var lineTotal = price * dto.Quantity;
// скидка 5% для VIP
if (customer.IsVip)
lineTotal *= 0.95m;
order.Items.Add(new OrderItem
{
ProductId = dto.ProductId,
Quantity = dto.Quantity,
UnitPrice = price,
LineTotal = lineTotal
});
}
order.Total = order.Items
.Sum(i => i.LineTotal);
if (customer.CreditUsed + order.Total > customer.CreditLimit)
throw new InvalidOperationException("Кредитный лимит превышен ");
_db.Orders.Add(order);
_db.SaveChanges();
}
Что здесь не так?
1. Разрозненные правила: применение скидок, проверка остатков и проверки кредитного лимита зарыты внутри сервиса.
2. Тесная связь: OrderService должен знать о ценах, запасах и EF Core только для того, чтобы разместить заказ.
3. Болезненное тестирование: каждому модульному тесту нужны моки для доступа к БД, ценообразования, запасов и потоки для VIP и не VIP клиентов.
Наша цель - внедрить эти правила в домен, чтобы прикладной уровень занимался только оркестрацией.
Руководящие принципы
1. Размещаем защитные инварианты близко к данным.
Проверки остатков, скидок и кредита относятся к месту, где находятся данные — внутри агрегата Order.
2. Раскрываем намерение, скрываем механику.
Прикладной уровень должен читаться как история: «разместить заказ», а не «рассчитать итоги, проверить кредит, записать в БД».
3. Выполняем рефакторинг в срезах.
Каждый ход безопасен и компилируется; никаких больших переписываний.
4. Балансируем чистоту с прагматизмом.
Изменяйте правила только тогда, когда выгода (ясность, безопасность, тестируемость) перевешивает дополнительные строки кода.
Продолжение следует…
Источник: https://www.milanjovanovic.tech/blog/from-anemic-models-to-behavior-driven-models-a-practical-ddd-refactor-in-csharp
>>Click here to continue<<