Сохраняем Богатую Доменную Модель в EF Core. Начало
С богатой доменной моделью всегда была проблема. В теории это хорошо, но EF Core нужны публичные сеттеры и публичный конструктор без параметров. ORM вроде как навязывает нам анемичную модель. Однако сейчас это не так: EF Core с удовольствием сохранит полностью инкапсулированный агрегат, и результатом станет доменная модель, которая обеспечивает соблюдение инвариантов в одном месте, а ORM будет делать свою часть работы. Проанализируем агрегат от начала до конца и рассмотрим все места, где, предположительно, EF Core и инкапсуляция могут конфликтовать.
Агрегат, который мы хотим сохранить
Наш домен - домашняя пивоварня (потому что заказы товаров уже надоели). Batch - партия брожения. Вы измеряете плотность (gravity) в
AddReading(), и как только она стабилизируется, разливаете пиво по бутылкам в Bottle(). Вот модель партии, без публичных сеттеров, создающаяся через фабричный метод Start() и изменяющая состояние через методы:public sealed class Batch
{
private readonly List<FermentationReading>
_readings = [];
private readonly List<IDomainEvent>
_events = [];
private DateTime? _bottledAt;
private Batch() { } // Для EF Core
private Batch(BatchId id,
RecipeId recipeId, Volume volume)
{
Id = id;
RecipeId = recipeId;
Volume = volume;
Status = Status.Fermenting;
}
public BatchId Id { get; private set; }
public RecipeId RecipeId { get; private set; }
public BatchStatus Status { get; private set; }
public Volume Volume { get; private set; }
public IReadOnlyCollection<FermentationReading>
Readings => _readings.AsReadOnly();
public IReadOnlyCollection<IDomainEvent>
DomainEvents => _events.AsReadOnly();
public static Batch Start(
RecipeId recipeId, Volume volume) =>
new(new BatchId(Guid.CreateVersion7()), recipeId, volume);
public void AddReading(
Gravity gravity, DateTime takenAt)
{
if (Status != Status.Fermenting)
throw new DomainException("Измерения доступны только в процессе ферментации.");
_readings.Add(new FermentationReading(gravity, takenAt));
}
public void Bottle(TimeProvider tp)
{
if (Status != Status.Fermenting)
throw new DomainException("Только ферментированная партия может быть бутилирована.");
Gravity[] lastTwo = _readings
.OrderBy(r => r.TakenAt)
.TakeLast(2)
.Select(r => r.Gravity)
.ToArray();
if (lastTwo.Length < 2 || lastTwo[0] != lastTwo[1])
throw new DomainException("Плотность должна быть одинаковой в последних 2х измерениях.");
Status = Status.Bottled;
_bottledAt = tp.GetUtcNow().UtcDateTime;
_events.Add(new BatchBottled(Id));
}
public void ClearDomainEvents() => _events.Clear();
}
public readonly record struct BatchId(Guid Value);
public readonly record struct RecipeId(Guid Value);
public readonly record struct Gravity(decimal Value);
public sealed record Volume(decimal Amount, string Unit);
public sealed record FermentationReading(decimal Gravity, DateTime TakenAt);
Дочерние сущности агрегата – записи, поэтому равенство по значению достаётся нам бесплатно.
Три особенности здесь напрямую заимствованы из архитектуры агрегатов:
1.
RecipeId ссылается на другой агрегат только по ID;2.
FermentationReading — дочерняя сущность, которая существует и исчезает вместе с агрегатом (Batch);3.
_bottledAt — приватное поле без каких-либо свойств.Каждая из особенностей, предположительно, «ломает» EF Core, поэтому далее рассмотрим их по порядку.
Продолжение следует…
Источник: https://milanjovanovic.tech/blog/persisting-a-rich-domain-model-with-ef-core

