![](https://habrastorage.org/getpro/habr/upload_files/ae3/7b1/d7e/ae37b1d7e101b823216e4e3fbb8fe43e.jpg)
Привет, Хабравчане! Меня зовут Валентин, я 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.
ссылка на оригинал статьи https://habr.com/ru/articles/729552/
Добавить комментарий