Сохраняем Богатую Доменную Модель в 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

