TGViewer
.NET Разработчик .NET Разработчик @netdeveloperdiary · 6.74K subscribers
Post #3301 1.6K
День 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
More from @netdeveloperdiary
  1. Sep 26, 2026День 2796. #ЗаметкиНаПолях #AI Рабочий процесс с Copilot для .NET. Продолжение Начало Три…
  2. Sep 25, 2026День 2795. #ЗаметкиНаПолях #AI Рабочий процесс с Copilot для .NET. Начало Проблема с позиц…
  3. Sep 24, 2026День 2794. #Оффтоп #Здоровье Сегодня будет необычный пост. Завтра в Москве стартует конфер…
  4. Sep 23, 2026День 2793. #ЗаметкиНаПолях #SQL 10 Редких Возможностей SQL, Которые Стоит Знать Каждому. Ч…
  5. Sep 22, 2026День 2792. #ЗаметкиНаПолях #SQL 10 Редких Возможностей SQL, Которые Стоит Знать Каждому. Ч…
  6. Sep 21, 2026🔍Тестовое собеседование с Senior C# разработчиком уже завтра 22 сентября(уже завтра!) в 1…
Threads Profile ViewerView any public Threads profile without an account.Open ThreadLook →Writing with AI? Make it sound human.Metric37 rewrites AI drafts so they read naturally. Free AI detector, 1,500 words free.Try Metric37 →