Как подружить DDD и Entity Framework Core?

от автора

Привет, Хабравчане! Меня зовут Валентин, я 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.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Какой подход используетcя вами или вашей командой?

51.79% Непосредственное хранение доменных моделей в БД29
41.07% Маппинг доменных моделей в сущности БД23
7.14% Другой подход4

Проголосовали 56 пользователей. Воздержались 12 пользователей.

ссылка на оригинал статьи https://habr.com/ru/articles/729552/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *