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

День 2757. #ЗаметкиНаПолях #DDD
Сохраняем Богатую Доменную Модель в 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
👍9👎1
August 18, 2026 1.2K 1 17