Привет, Хабравчане! Меня зовут Валентин, я backend‑разработчик в компании Bimeister.
Уже почти как год вместе с командой разрабатываем новый продукт с применением Domain‑driven design подхода. Как же так получилось?
Так вот, разработка начиналась совершенно с нуля и это была хорошая возможность применить данный подход и попробовать его на практике. В момент начала разработки, перед нашей командой сразу встал вопрос: «А как же хранить аггрегаты, сущности, value‑object'ы в базе данных с использованием EF Core? ». Если вы только начинаете применять DDD и перед вами и вашей командой встала такая же проблема, то эта статья поможет вам приблизиться к ее решению, да пребудет с вами сила Эванса!
Для кого эта статья?
Статья рассчитана на читателей, знакомых с базовой теорией DDD и шаблонами тактического проектирования. А также с опытом EF Core и конфигурирования сущностей через Fluent API.
О чём будет идти речь?
Я расскажу про два подхода к вопросу о хранении сущностей из мира DDD, которые были использованы внутри нашей команды, приведу возможные плюсы и минусы каждого из подходов, затрону конфигурацию сущностей через Fluent API, а также, расскажу почему нам пришлось провести крупный рефакторинг проекта и заменить первый подход на второй. Примеры ниже будут приводиться с использованием следующих технологий: ASP.NET Core, EF Core (Code-First) и PostgreSQL.
Также хочу отметить, что в статье примеры будут приводиться в укороченном варианте, чтобы показать принципиальную разницу между подходами. Пример кода целиком, вы можете найти по ссылке в конце статьи, где будут представлены: конфигурации сущностей, настройка маппинга, примеры использования в репозиториях, а также получившиеся миграции для каждого из подходов.
Контекст
Наша команда разрабатывает продукт под названием «Учет отказов и неисправностей». Его целью является управление процессами технического обслуживания и ремонта эксплуатируемых активов предприятия. Основными понятиями являются: Сообщение о техническом отказе (Notification
), Заказ (Order
), Технический объект (TechnicalObject
).
В случае обнаружения неисправностей работник создает новые сообщения о техническом отказе.
Далее создается заказ на устранение неисправностей, куда включаются необходимые сообщения о техническом отказе.
Заказ позволяет запланировать необходимые ресурсы (временные, фиансовые, материальные и человеческие) для устранения неисправностей.
Заказы помогают в ресурсном планировании, организации ремонтов, а также в запланированных технических обслуживаниях оборудования.
Технический объект описывает любой имеющийся актив на предприятии. При создании сообщения о техническом отказе добавляется связь с техническим объектом, на котором была выявлена неисправность. В случае создания заказа также добавляем необходимый технический объект, на котором будут выполняться планируемые работы.
Подход №1. Непосредственное хранение доменных моделей в БД
Для примера будут использованы две сущности, выступающие в роли корня аггрегата: Notification
и TechnicalObject
. У каждой из них имеются приватные поля, описывающие внутреннее состояние сущности, приватные списки, поверх которых в дальнейшем будет строиться необходимая бизнес-логика, и соответствующие публичные свойства, через которые мы можем получить значения, которые лежат в этих списках. Также обе сущности имеют набор value-object'ов.
public sealed class Notification : AggregateRoot
{
private bool _isDeleted;
private int _currentStatusId;
private readonly List<NotificationComment> _comments;
private readonly List<NotificationTechnicalObjectLink> _technicalObjects;
public string Name { get; private set; }
public long Number { get; private set; }
public DateTimeOffset DetectedAt { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? CompletedAt { get; private set; }
public Guid CreatedBy { get; private set; }
public Guid? Executor { get; private set; }
public Guid TechnicalObjectId { get; private set; }
public Breakdown Breakdown { get; private set; }
public NotificationStatus CurrentStatus => NotificationStatus.GetById(_currentStatusId);
public uint Version { get; private set; }
public IReadOnlyCollection<NotificationComment> Comments => _comments;
public IReadOnlyCollection<NotificationTechnicalObjectLink> TechnicalObjects => _technicalObjects;
}
public abstract class TechnicalObject : AggregateRoot
{
protected readonly List<TechnicalObject> _children;
public string Name { get; private set; }
public string Code { get; private set; }
public string Description { get; private set; }
public char Category { get; private set; }
public Guid CreatedBy { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public Weight Weight { get; private set; }
public Acquisition Acquisition { get; private set; }
public Manufacturer Manufacturer { get; private set; }
public TechnicalObjectType Type { get; protected set; }
public Guid ParentId { get; private set; }
public TechnicalObject Parent { get; private set; }
public IReadOnlyCollection<TechnicalObject> Children => _children;
}
Помимо конфигурирования привычных публичных свойств, перед нами встает задача настройки хранения приватных полей, построения связей между сущностями, используя приватные поля и свойства доступа к приватным спискам сущностей, а также хранения value-object'ов.
Для приватного поля _currentStatusId
используем следующую настройку через Fluent API EF Core:
builder
.Property<int>("_currentStatusId")
.UsePropertyAccessMode(PropertyAccessMode.Field)
.HasColumnName("StatusId");
В данном случае мы сообщаем EF Core, что у сущности есть свойство, которое представляет собой приватное поле, настраиваем доступ к свойству и задаем желаемое название колонки в будущей таблице.
Далее нам необходимо создать связь между сущностью Notification
и NotificationStatus
, используя _currentStatusId
как FK. Это отношение можно создать следующим образом:
builder
.HasOne<NotificationStatus>()
.WithMany()
.HasForeignKey("_currentStatusId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
Здесь мы создаем связь один-ко-многим, указываем _currentStatusId
в виде FK, задаем ограничение на обязательность наличия этой связи у сущности, а также убираем каскадное удаление.
Далее необходимо настроить soft delete, для этого по аналогии со статусом настроим поле _isDeleted
и наложим глобальный фильтр на запросы сущности, чтобы нам возвращались только не удалённые записи:
builder.HasQueryFilter(notification =>
EF.Property<bool>(notification, "_isDeleted") == false);
builder
.Property<bool>("_isDeleted")
.UsePropertyAccessMode(PropertyAccessMode.Field)
.HasColumnName("IsDeleted");
Сущность Notification
должна иметь связи со своей зависимой сущностью Comments
, а также связь с TechnicalObjects
. Настраивается это следующим образом:
builder
.HasMany(notification => notification.Comments)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder
.HasOne<TechnicalObject>()
.WithMany()
.HasForeignKey(notification => notification.TechnicalObjectId)
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
builder
.HasMany(notification => notification.TechnicalObjects)
.WithOne()
.HasForeignKey(link => link.NotificationId)
.OnDelete(DeleteBehavior.Cascade);
Можно заметить, что связь один-ко-многим между Notification
и TechnicalObject
строится без использования навигирующего свойства, в сущности указан только TechnicalObjectId
, так как корневая сущность аггрегата может ссылаться на другую корневую сущность только при помощи идентификатора.
Далее необходимо указать навигирующие свойства, по которым будут строиться связи и настроить доступ к значениям свойств через приватные списки сущности:
builder
.Navigation(notification => notification.Comments)
.UsePropertyAccessMode(PropertyAccessMode.Field);
builder
.Navigation(notification => notification.TechnicalObjects)
.UsePropertyAccessMode(PropertyAccessMode.Field);
Настройка value-object'ов осуществляется с использованием методов OwnsOne()
и OwnsMany()
. В случае с Breakdown
конфигурация выглядит следующим образом:
builder.OwnsOne(notification => notification.Breakdown, subbuilder =>
{
subbuilder
.Property(breakdown => breakdown.Start)
.HasColumnName("BreakdownStart");
subbuilder
.Property(breakdown => breakdown.Finish)
.HasColumnName("BreakdownFinish");
subbuilder
.Property(breakdown => breakdown.Duration)
.HasColumnName("BreakdownDuration");
});
В данном примере конфигурируются свойства value-object'а Breakdown
с указанием необходимых нам имен, если не настраивать имена колонок, то имена будут выбраны по умолчанию в соответствии с шаблоном ClassName_PropertyName
.
Также при конфигурации корневых сущностей аггрегатов не стоит забывать о свойстве DomainEvents
, которое необходимо игнорировать при создании миграции:
builder.Ignore(notification => notification.DomainEvents);
В конечном счете, конфигурации для двух корневых сущностей аггрегатов выглядят следующим образом:
Конфигурация сущности Notification
internal sealed class NotificationEntityConfiguration
: EntityConfiguration<Notification, Guid>
{
public override void Configure(EntityTypeBuilder<Notification> builder)
{
base.Configure(builder);
builder.HasQueryFilter(notification => EF.Property<bool>(notification, "_isDeleted") == false);
builder
.Ignore(notification => notification.DomainEvents);
builder
.Property(notification => notification.Version)
.IsConcurrencyToken()
.IsRequired();
builder
.Property(notification => notification.Name)
.IsRequired();
builder
.Property(notification => notification.Number)
.IsRequired();
builder
.Property(notification => notification.DetectedAt)
.IsRequired();
builder
.Property(notification => notification.CreatedAt)
.IsRequired();
builder
.Property(notification => notification.CreatedBy)
.IsRequired();
builder.OwnsOne(notification => notification.Breakdown, subbuilder =>
{
subbuilder
.Property(breakdown => breakdown.Start)
.HasColumnName("BreakdownStart");
subbuilder
.Property(breakdown => breakdown.Finish)
.HasColumnName("BreakdownFinish");
subbuilder
.Property(breakdown => breakdown.Duration)
.HasColumnName("BreakdownDuration");
});
builder
.Property<int>("_currentStatusId")
.UsePropertyAccessMode(PropertyAccessMode.Field)
.HasColumnName("StatusId");
builder
.HasOne<NotificationStatus>()
.WithMany()
.HasForeignKey("_currentStatusId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
builder
.HasMany(notification => notification.Comments)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder
.Navigation(notification => notification.Comments)
.UsePropertyAccessMode(PropertyAccessMode.Field);
builder
.HasOne<TechnicalObject>()
.WithMany()
.HasForeignKey(notification => notification.TechnicalObjectId)
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
builder
.HasMany(notification => notification.TechnicalObjects)
.WithOne()
.HasForeignKey(link => link.NotificationId)
.OnDelete(DeleteBehavior.Cascade);
builder
.Navigation(notification => notification.TechnicalObjects)
.UsePropertyAccessMode(PropertyAccessMode.Field);
}
Конфигурация сущности TechnicalObject
internal sealed class TechnicalObjectEntityConfiguration
: EntityConfiguration<TechnicalObject, Guid>
{
public override void Configure(EntityTypeBuilder<TechnicalObject> builder)
{
base.Configure(builder);
//Configuring Table-Per-Hierarchy
builder
.HasDiscriminator(technicalObject => technicalObject.Type)
.HasValue<Equipment>(TechnicalObjectType.Equipment)
.HasValue<FunctionalLocation>(TechnicalObjectType.FunctionalLocation);
builder
.Ignore(technicalObject => technicalObject.DomainEvents);
builder.OwnsOne(technicalObject => technicalObject.Weight, subBuilder =>
{
subBuilder
.Property(weight => weight.Value)
.HasColumnName("Weight");
subBuilder
.Property(weight => weight.Unit)
.HasColumnName("WeightUnit");
});
builder.OwnsOne(technicalObject => technicalObject.Acquisition, subBuilder =>
{
subBuilder
.Property(acquisition => acquisition.Price)
.HasColumnName("AcquisitionPrice");
subBuilder
.Property(acquisition => acquisition.Currency)
.HasColumnName("AcquisitionCurrency");
subBuilder
.Property(acquisition => acquisition.Date)
.HasColumnName("AcquisitionDate");
});
builder.OwnsOne(technicalObject => technicalObject.Manufacturer, subBuilder =>
{
subBuilder
.Property(manufacturer => manufacturer.Name)
.HasColumnName("ManufacturerName");
subBuilder
.Property(manufacturer => manufacturer.Country)
.HasColumnName("ManufacturerCountry");
subBuilder
.Property(manufacturer => manufacturer.Model)
.HasColumnName("ManufacturerModel");
subBuilder
.Property(manufacturer => manufacturer.PartNumber)
.HasColumnName("ManufacturerPartNumber");
subBuilder
.Property(manufacturer => manufacturer.SerialNumber)
.HasColumnName("ManufacturerSerialNumber");
subBuilder
.Property(manufacturer => manufacturer.ManufacturedAt)
.HasColumnName("ManufacturedAt");
});
builder
.HasOne(technicalObject => technicalObject.Parent)
.WithMany(technicalObject => technicalObject.Children)
.HasForeignKey(technicalObject => technicalObject.ParentId)
.OnDelete(DeleteBehavior.Cascade);
builder
.Navigation(technicalObject => technicalObject.Children)
.UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
Плюсы данного подхода:
Простой и быстрый процесс разработки;
Отсутствие дополнительного маппинга.
Минусы данного подхода:
Аггрегаты имеют не относящиеся к домену свойства;
Усложняется конфигурация сущностей в EF Core;
Использование строковых имен приватных полей при конфигурации;
Зависимость от инфраструктуры и реализации хранения.
Подводя итоги обзора использования подхода непосредственного хранения доменных моделей в БД, хочу привести ряд ключевых причин, почему же мы все таки решили использовать маппинг доменных моделей в сущности БД:
Необходимость в soft delete — при реализации данной функциональности, мы с командой определились, что не хотим в доменный слой зашивать конкретную реализацию подхода к удалению, поэтому наличие свойства
IsDeleted
в доменных моделях нас не устраивало.Использование навигирующих свойств — свойства необходимые исключительно для настройки связей между сущностями в EF Core. Также при их наличии удобно строить linq-запросы с подгрузкой связанных сущностей.
Параллельный доступ к данным — EF Core имеет встроенный механизм оптимистичной блокировки, для его реализации сущность должна иметь свойство Version, которое настраивается через Fluent API как ConcurrencyToken или RowVersion.
Использование Table-Per-Hierarchy (TPH) подхода — вариант хранения иерархии сущностей в EF Core, когда базовый тип и его наследники сохраняются в одну таблицу. Для его использования сущность должна иметь свойство-дескриминатор, которое настраивается через Fluent API. На основе его значения EF Core понимает какой тип необходимо создавать.
Enumeration (Smart Enums) — приходилось подгонять их реализацию под использование с EF Core. Например, при реализации паттерна State образовывалась иерархия, хранение которой требовало дополнительной настройки TPH, что нас не устраивало.
Реализация хранения древовидной структуры в БД — для реализации мы остановились на паттерне Materialized Path, где необходимо использовать встроенный тип
Ltree
провайдера PostgreSQL для EF Core.
Если вы интересуетесь темой хранения деревьев в реляционных БД, то могу посоветовать две очень хорошие статьи моих коллег: Обзор паттернов хранения деревьев в реляционных БД и Materialized Path – создаём своё первое дерево.
Подход №2. Маппинг доменных моделей в сущности БД
При использовании данного подхода необходимо создать инфраструктурные сущности, которые и будут использоваться при взаимодействии с БД в EF Core, а также настраиваться необходимым образом. Они полностью копируют доменные сущности по составу атрибутов, а также дополняются необходимыми атрибутами для реализации хранения в БД. Ниже будут представлены измененные доменные сущности, из которых мы удалили все свойства, которые не относятся к домену:
public sealed class Notification : AggregateRoot
{
private readonly List<Guid> _technicalObjects;
private readonly List<NotificationComment> _comments;
public string Name { get; private set; }
public long Number { get; private set; }
public DateTimeOffset DetectedAt { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? CompletedAt { get; private set; }
public Guid CreatedBy { get; private set; }
public Guid? Executor { get; private set; }
public Guid TechnicalObjectId { get; private set; }
public Breakdown Breakdown { get; private set; }
public NotificationStatus CurrentStatus { get; private set; }
public IReadOnlyCollection<NotificationComment> Comments => _comments;
public IReadOnlyCollection<Guid> TechnicalObjects => _technicalObjects;
}
public abstract class TechnicalObject : AggregateRoot
{
protected readonly List<TechnicalObject> _children;
public string Name { get; private set; }
public string Code { get; private set; }
public string Description { get; private set; }
public char Category { get; private set; }
public Guid CreatedBy { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public Weight Weight { get; private set; }
public Acquisition Acquisition { get; private set; }
public Manufacturer Manufacturer { get; private set; }
public TechnicalObject Parent { get; private set; }
public IReadOnlyCollection<TechnicalObject> Children => _children;
Ниже представлены получившиеся сущности БД для Notification и TechnicalObject:
internal sealed class NotificationEntity : DbEntity<Guid>
{
public string Name { get; set; }
public long Number { get; set; }
public OwnedBreakdown Breakdown { get; set; }
public DateTimeOffset DetectedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public Guid CreatedBy { get; set; }
public Guid? Executor { get; set; }
public uint Version { get; set; }
public bool IsDeleted { get; set; }
public int StatusId { get; set; }
public NotificationStatusEntity Status { get; set; }
public Guid TechnicalObjectId { get; set; }
public TechnicalObjectEntity TechnicalObject { get; }
public ICollection<NotificationCommentEntity> Comments { get; set; }
public ICollection<NotificationTechnicalObjectLink> TechnicalObjects { get; set; }
}
internal abstract class TechnicalObjectEntity : DbEntity<Guid>
{
public string Name { get; set; }
public string Code { get; set; }
public char Category { get; set; }
public string Description { get; set; }
public Guid CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Weight Weight { get; set; }
public Acquisition Acquisition { get; set; }
public Manufacturer Manufacturer { get; set; }
public bool HasChildren { get; set; }
public TechnicalObjectEntityType Type { get; set; }
public LTree Path { get; set; }
public Guid? ParentId { get; set; }
public TechnicalObjectEntity Parent { get; set; }
public ICollection<TechnicalObjectEntity> Children { get; set; }
public ICollection<NotificationTechnicalObjectLink> Notifications { get; set; }
}
В данном подходе мы решили не отказываться в этих сущностях от value-object’ов и использовали их напрямую, с аналогичной настройкой из первого подхода. При желании можно от них отказаться и реализовать в виде свойств класса. В остальном конфигурация сущностей БД ничем не будет отличаться от стандартной, где не надо отдельно настраивать приватные поля, навигирующие свойства и т.д.
Плюсы данного подхода:
Независимость от инфраструктуры и реализации хранения;
Отсутствие ненужных свойств в доменной модели;
Простота конфигурации в EF Core;
Отсутствие необходимости использовать строковые имена приватных полей;
Возможность использования навигирующих свойств.
Минусы данного подхода:
Дополнительный маппинг со сложной настройкой;
Более сложный процесс разработки.
Заключение
Оба подхода имеют как сильные стороны, так и слабые. Возможно, названные плюсы и минусы могут варьироваться в зависимости от подхода к разработке, используемых технологий или от разрабатываемого проекта. В частности, в контексте используемых нами технологий, мы выявили для себя вышеназванные достоинства и недостатки каждого из подходов и сделали выбор в пользу второго.
При выборе способа хранения доменных моделей, я бы посоветовал отталкиваться от следующего:
В случае сложного и активно-развивающегося продукта, где скорее всего понадобится необходимость в атрибутах, описывающих конкретную реализацию и способ хранения или не относящихся к моделируемым бизнес-сценариям делайте выбор в пользу маппинга доменных моделей в сущности БД. Таким образом ваш доменный слой будет оставаться чистым и независимым от специфики конкретной реализации.
В ином случае, во избежание ненужного переусложнения вашего кода выбирайте подход с непосредственным хранением доменных моделей в БД.
Конечно, этот совет максимально абстрактный, в любом случае, необходимо исходить из конкретной моделируемой доменной области и требований к разработке.
В заключение хочу сказать, что мы с командой очень довольны, так как вовремя заметили надвигающиеся проблемы (примерно спустя полгода после начала разработки) и нам не пришлось переписывать большое количество кода. Это позволило нашей команде убрать из доменных моделей все атрибуты, которые были сугубо инфраструктурными, не имевшие никакого отношения к моделируемым бизнес-сценариям, и в будущем поддерживать наш доменный слой приложения более чистым и независимым.
А какой подход к хранению доменных моделей вы используете при разработке с применением DDD? Поделитесь вашим опытом решения данной проблемы в комментариях, будет очень интересно почитать, и, даже возможно, кто-то найдет в них решение для себя.
Ссылка на пример кода, приведенного в статье: Подходы к хранению доменных моделей в БД с использованием EF Core.
DimonSmart
Спасибо за интересную статью. Чуток покритикую :-) На мой взгляд структура выглядит слабо и не нормализовано. Это специально для статьи? Если да - то ок. А иначе, чуть разовью мысль. Если мы начинаем говорить про ddd и моделирование предметной области то надо четко отделять объекты от того как мы их видим.
Пример: Вам дали пачку денег и попросили составить реестр купюр. Вряд ли вы будете делать пометки прямо на банкнотах. Вы составите отдельный список. И будете в него записывать номера, номиналы и т.п. Если вас попросят рассортировать купюры по номиналу - то опять, сами купюры останутся неизменны - во просто положите их в разные кучки. И тут важно что кучка хранит в себе банкноту а не наоборот. С оборудованием тоже самое. Если вы описываете объект станок - то это объект сам по себе вес, производитель и т.п. А его включение в иерархии - это отдельно. Представьте, что у вас есть два абсолютно одинаковых станка. Тогда уместно хранить описание станка отельной таблицей. А экземпляры, где будет серийный номер, дата производства и т.п - отдельно. Если кто-то провидите ревизию станков - то это отдельный реестр кто, когда, состояние и т.п. Но сам Станок существует не зависимо от того есть о его учете запись в БД или нет.
И пара вопросов про детали:
Как поступаете если в объекте есть большой список? Грузите его всегда? Или делаете частично загруженные объекты? (How Incomplete DDD Aggregates Can Improve Application Performance )
Используете ли строго типизированные ID? (How Strongly Typed IDs Can Make Your Code More Expressive)