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

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

Начало

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

Приватные конструкторы и приватные сеттеры просто работают
Когда EF Core материализует сущность, он не использует публичный API. Он вызывает приватный конструктор без параметров и записывает данные в свойства через их базовые поля, приватные сеттеры и т.п.. Поэтому пустой приватный конструктор - единственная уступка, которую доменная модель делает ORM. Загрузка и изменение партии выглядят обычно:
Batch batch = await context.Batches
.SingleAsync(b => b.Id == batchId);

batch.AddReading(new Gravity(1.012m), timeProvider.GetUtcNow().UtcDateTime);

await context.SaveChangesAsync();

Трекер изменений считывает те же самые базовые поля, поэтому приватные сеттеры ничего не скрывают от SaveChanges.

EF может даже использовать привязку через параметризованный конструктор, сопоставляя параметры с отображаемыми свойствами по имени и типу, независимо от того, приватные они или нет. Но это работает только для скалярных типов. Свойства навигации не могут быть привязаны через конструктор, поэтому агрегату с коллекцией по-прежнему нужен конструктор без параметров. EF также пропускает фабричные валидации при загрузке, что правильно: повторное выполнение валидации во время материализации сделало бы старые объекты недоступными для загрузки при изменении правил в будущем.

Типизированные ID и ссылки на другие агрегаты
BatchId и RecipeId — строго типизированные идентификаторы, сопоставляемые с помощью преобразования значений:
public sealed class BatchConfiguration
: IEntityTypeConfiguration<Batch>
{
public void Configure(EntityTypeBuilder<Batch> bld)
{
bld.ToTable("batches");
bld.HasKey(b => b.Id);
bld.Property(b => b.Id)
.HasConversion(id => id.Value,
value => new BatchId(value))
.ValueGeneratedNever();

bld.Property(b => b.RecipeId)
.HasConversion(id => id.Value,
value => new RecipeId(value));
}
}

ValueGeneratedNever важно: мы генерируем Guid.CreateVersion7() в фабрике, поэтому говорим EF не вмешиваться.
RecipeId не является свойством навигации для Recipe. Рецепт — это самостоятельный агрегат, но здесь он нам не нужен.

Инкапсулированная коллекция
По соглашению, EF находит поле _readings для навигации по Readings. Но его лучше настроить явно, чтобы сопоставление сохранялось даже после переименования:
// продолжение BatchConfiguration.Configure()

bld.HasMany<FermentationReading>("_readings")
.WithOne()
.HasForeignKey("batch_id");

bld.Navigation("_readings")
.UsePropertyAccessMode(PropertyAccessMode.Field)
.AutoInclude();

PropertyAccessMode.Field указывает EF читать и записывать поле, не обращаясь к публичному представлению Readings. AutoInclude() важно добавлять, т.к. метод Bottle() читает _readings, поэтому использование частично загруженного Batch небезопасно. WithOne() без аргументов означает, что дочерний элемент не имеет навигации обратно к Batch; внешний ключ batch_id существует только как теневое свойство.

Состояние без свойств
_bottledAt не имеет свойств, только приватное поле, и EF всё равно его сопоставляет:
bld.Property<DateTime?>("_bottledAt")
.HasColumnName("bottled_at");

Однако, чтобы отфильтровать по этому полю, придётся добавлять в запрос «костыль»:
var thisWeek = await context.Batches
.Where(b => EF.Property<DateTime?>(b, "_bottledAt") >= weekAgo)
.ToListAsync();

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

Окончание следует…

Источник:
https://milanjovanovic.tech/blog/persisting-a-rich-domain-model-with-ef-core
👎2👍1
August 19, 2026 1.1K 4 15