.NET Разработчик: post #3303 — TG.ME

День 2759. #ЗаметкиНаПолях #DDD
Сохраняем Богатую Доменную Модель в EF Core. Окончание

Начало
Продолжение

Продолжаем разбирать, как EF Core сохраняет и загружает богатый агрегат (см. код в предыдущем посте).

Объекты-значения: принадлежащие (Owned), комплексные (Complex) типы и преобразования
Volume — объект-значение, а многосвойственные объекты-значения отображаются как комплексные типы, хранящие свои элементы непосредственно в таблице владельца (без объединения, без отдельной идентификации):
// продолжение BatchConfiguration.Configure()

bld.ComplexProperty(b => b.Volume, vol =>
{
vol.Property(v => v.Amount)
.HasColumnName("vol_amount");
vol.Property(v => v.Unit)
.HasColumnName("vol_unit");
});


Комплексные типы были добавлены в EF Core 8 (без необязательных свойств и коллекций); в EF Core 10 эти ограничения сняты. В EF 8 или 9 коллекцию объектов-значений можно преобразовать в коллекцию принадлежащего типа, которая работает везде (но содержит скрытый теневой ключ, т.к. EF рассматривает их как сущности, притворяющиеся значениями). Допустим, наш Batch имел бы коллекцию объектов-значений - лог добавлений хмеля, тогда в EF 8 и 9 мы могли бы связать его с отдельной таблицей:
bld.OwnsMany(b => b.HopAdditions, hop =>
{
hop.ToTable("hop_additions");
hop.WithOwner()
.HasForeignKey("batch_id");
});


Перечисления конвертируются в простой текст, так что БД остаётся читаемой:
bld.Property(b => b.Status)
.HasConversion<string>()
.HasMaxLength(20);

Преобразованные свойства имеют один общий недостаток: LINQ работает с типом провайдера, поэтому сортировка по статусу даёт алфавитный порядок строк (Bottled, Dumped, Fermenting), а не порядок задуманного нами жизненного цикла.

События домена не должны попадать в схему
IDomainEvent не является сущностью, поэтому укажите EF игнорировать коллекцию DomainEvents:
bld.Ignore(b => b.DomainEvents);

Конечно, доменные события должны быть обработаны где-то. И лучшее место для этого – перехватчик сохранения изменений:
public class DomainEventsInterceptor(
// внедряем диспетчер доменных событий
) : SaveChangesInterceptor
{
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData data,
int result,
CancellationToken ct = default)
{
var events = data.Context!.ChangeTracker
.Entries<Batch>()
.SelectMany(e =>
{
var ev = e.Entity.DomainEvents.ToList();
e.Entity.ClearDomainEvents();
return ev;
})
.ToList();

// … обрабатываем события с помощью диспетчера …

return await base.SavedChangesAsync(data, result, ct);
}
}


Домен не ссылается на EF Core
Все конфигурации сопоставлений находятся в BatchConfiguration, ни один из них не находится в Batch: никаких атрибутов сопоставления, никакого базового класса ORM!
DbContext находится на уровне инфраструктуры и получает всю конфигурацию из своей сборки:
public sealed class BreweryDbContext(
DbContextOptions<BreweryDbContext> opts)
: DbContext(opts)
{
public DbSet<Batch> Batches => Set<Batch>();

protected override void OnModelCreating(ModelBuilder mb)
{
mb.ApplyConfigurationsFromAssembly(
typeof(BreweryDbContext).Assembly);
}
}


Итого
Возражение о том, что «EF Core заставляет создавать анемичные модели», устарело много лет назад. Приватный конструктор, вспомогательные поля, преобразование значений и комплексные типы покрывают все потребности полностью инкапсулированного агрегата, и всё это находится в классах конфигурации, которые домен никогда не видит. Реальные ограничения сводятся к двум:
- свойства сложных типов и свойства навигации не могут быть привязаны через параметры конструктора,
- преобразованные поля или поля без свойств транслируются в тип провайдера в запросах.

ORM никогда не заставлял вашу доменную модель быть анемичной. Это задача сопоставления, выполняемая один раз для каждого агрегата.

Источник:
https://milanjovanovic.tech/blog/persisting-a-rich-domain-model-with-ef-core
👍3👎1
August 20, 2026 951 15