Domain-Driven Design (DDD) звучит как серебряная пуля. Когда мы начинаем проект на ASP.NET, идея четкого разделения на слои, изоляция бизнес-логики в домене и использование паттернов вроде Repository и Unit of Work кажется идеальной архитектурой.
Но есть один нюанс: магия DDD начинает испаряться ровно в тот момент, когда количество агрегатов (реестров) в проекте переваливает за 30. То, что было элегантным решением для CRM с 10 сущностями, превращается в бюрократический ад для ERP-системы или крупного маркетплейса.
В этой статье я разберу, почему классический DDD в ASP.NET (особенно в связке с Entity Framework Core) становится узким местом на масштабных проектах.
Проблема «Толстых» Агрегатов и Производительности
Один из главных постулатов DDD гласит: один запрос — один агрегат. Агрегат должен быть консистентным, и загружать его нужно целиком.
Принцип «Один запрос — один агрегат» (также известен как «одна транзакция на агрегат») — принцип предметно-ориентированного проектирования (Domain-Driven Design, DDD). Он предполагает, что запрос клиента влияет на один агрегат, и изменения в системе происходят через методы этого агрегата.

Реальность
Когда у вас 30+ реестров, у каждого из них есть глубокие графы вложенных объектов (Value Objects, дочерние сущности). Если следовать правилам строго:
Вы начинаете использовать
IncludeиThenIncludeв Entity Framework Core, которые порождают монструозные SQL-запросы с десяткамиJOIN.
var orders = await context.Orders .Include(o => o.Customer) .ThenInclude(c => c.Address) .Include(o => o.Customer) .ThenInclude(c => c.Contacts) .Include(o => o.Items) .ThenInclude(i => i.Product) .ThenInclude(p => p.Category) .Include(o => o.Items) .ThenInclude(i => i.Discount) .ToListAsync();
SELECT -- Orders [o].[Id], [o].[OrderNumber], [o].[OrderDate], [o].[CustomerId], [o].[Total], -- Customer (через первый Include) [c].[Id], [c].[Name], [c].[Email], [c].[Phone], [c].[CustomerId], -- Address (через ThenInclude c.Address) [a].[Id], [a].[Street], [a].[City], [a].[PostalCode], [a].[Country], [a].[AddressId], -- Contacts (через второй ThenInclude c.Contacts) [c1].[Id], [c1].[ContactType], [c1].[Value], [c1].[CustomerId], -- Items (через Include o.Items) [i].[Id], [i].[OrderId], [i].[ProductId], [i].[Quantity], [i].[Price], -- Product (через ThenInclude i.Product) [p].[Id], [p].[Name], [p].[Description], [p].[Price], [p].[CategoryId], -- Category (через ThenInclude p.Category) [cat].[Id], [cat].[Name], [cat].[Description], [cat].[ParentId], -- Discount (через второй ThenInclude i.Discount) [d].[Id], [d].[Code], [d].[Amount], [d].[Type], [d].[ItemId] FROM [Orders] AS [o] -- Customer (первый Include) LEFT JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id] -- Address (ThenInclude c.Address) LEFT JOIN [Addresses] AS [a] ON [c].[AddressId] = [a].[Id] -- Contacts (ThenInclude c.Contacts) LEFT JOIN [Contacts] AS [c1] ON [c].[Id] = [c1].[CustomerId] -- Items (второй Include) LEFT JOIN [OrderItems] AS [i] ON [o].[Id] = [i].[OrderId] -- Product (ThenInclude i.Product) LEFT JOIN [Products] AS [p] ON [i].[ProductId] = [p].[Id] -- Category (ThenInclude p.Category) LEFT JOIN [Categories] AS [cat] ON [p].[CategoryId] = [cat].[Id] -- Discount (ThenInclude i.Discount) LEFT JOIN [Discounts] AS [d] ON [i].[DiscountId] = [d].[Id] ORDER BY [o].[Id], [c].[Id], [a].[Id], [c1].[Id], [i].[Id], [p].[Id], [cat].[Id], [d].[Id]
Чтобы обновить одно поле в заголовке заказа, вы вынуждены выгружать из БД 50 вложенных позиций (OrderItems), потому что они являются частью агрегата
Order.
На 5-10 реестрах это еще терпимо. Когда реестров 40, а в каждом из них по 5-6 уровней вложенности, производительность падает катастрофически. Время отклика API растет, а разработчики начинают нарушать принципы DDD, добавляя специальные «читаемые модели» (Read Models) прямо в доменный слой, размывая границы.
Лавинообразный рост «Спецификаций» (Specifications)
Для инкапсуляции логики выборки в DDD часто используют паттерн Specification. Это удобно, когда нужно переиспользовать условия фильтрации.

Проблема
При 30+ реестрах папка Specifications превращается в свалку из сотен классов. Вы быстро осознаете, что:
Спецификации сложно композировать. В реальных бизнес-задачах фильтры зависят от роли пользователя, статуса документа, временных зон и связанных данных. Попытки склеить спецификации через
AndиOrприводят к генерации неоптимального SQL.Утечка абстракций. Спецификации, написанные под EF Core, часто завязаны на
IQueryable. Как только вам нужно будет вытащить данные через Dapper (для отчета) или через gRPC, спецификация становится бесполезной.
Разработка занимает больше времени на написание «красивых» спецификаций и репозиториев, чем на реализацию самой бизнес-логики.
Инфраструктурная сложность: Unit of Work и Scoped DbContext
В ASP.NET Core стандартный подход — Scoped DbContext. В малых проектах он отлично играет роль Unit of Work. Но в больших системах с 30+ реестрами появляются кросс-агрегатные транзакции.
Где боль?
DDD учит нас, что одна транзакция = изменение одного агрегата. Но бизнесу плевать на DDD. Бизнесу нужно, чтобы при создании «Заказа» одновременно создавался «Платеж», резервировался «Товар» на складе и создавалась «Задача» в колл-центре.
Если у вас 30+ реестров, вы неизбежно столкнетесь с ситуациями, где один Use Case должен менять 5+ агрегатов.
Использовать одну транзакцию БД (классический Unit of Work) — значит нарушить DDD и создать гигантские God-сервисы.
Использовать распределенные транзакции (Saga, Outbox) — значит в 10 раз увеличить сложность кода.
В проектах с 30+ реестрами часто оказывается, что 70% кода — это не бизнес-логика, а инфраструктурный «клей» для синхронизации агрегатов между собой.
Навигация по коду: «Ад» от количества файлов
DDD подразумевает глубокую структуру папок по слоям (API, Application, Domain, Infrastructure) и по модулям (Features).
При 30+ реестрах это выливается в проект с более чем 1000+ файлов. Простой кейс «Добавить поле в сущность» требует изменений в 10-15 файлах:
Доменная сущность.
Конфигурация маппинга (Fluent API).
Миграция.
Команда (Command).
Валидатор команды (FluentValidation).
Обработчик (Handler).
DTO.
Маппер (AutoMapper/Manual).
Возможно, спецификация.
Обновление тестов (Unit/Integration).
Это не просто «неудобно». Это снижает скорость итераций. В больших коммерческих проектах время — деньги, и жесткая приверженность DDD начинает восприниматься как наследие, требующее рефакторинга.
Заключение
DDD в ASP.NET — это мощный инструмент для моделирования сложной бизнес-логики, но он не масштабируется линейно. Если у вас в проекте более 30 реестров, и вы пытаетесь для каждого из них построить классическую луковую архитектуру (Onion Architecture) с репозиториями, спецификациями и богатой доменной моделью, вас ждет:
Падение производительности из-за тяжелых агрегатов.
Медленная разработка новых фичей (из-за необходимости трогать 10+ слоев).
Проблемы с поддержкой и онбордингом новых разработчиков.
Спасением в больших проектах является гибридный подход: использование принципов DDD только в зонах высокой сложности (ядре) и применение более прагматичных подходов (CQRS, Vertical Slices, Feature-Sliced Design) для периферийных реестров и запросов.
Помните: архитектура должна помогать бизнесу, а не быть фетишем. Если ради поддержки принципов DDD вы начинаете тратить в 2 раза больше времени на реализацию простого справочника — возможно, вы стали заложником своих же абстракций.
