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

Комментарии (1)


  1. DimonSmart
    20.04.2023 04:51

    Спасибо за интересную статью. Чуток покритикую :-) На мой взгляд структура выглядит слабо и не нормализовано. Это специально для статьи? Если да - то ок. А иначе, чуть разовью мысль. Если мы начинаем говорить про ddd и моделирование предметной области то надо четко отделять объекты от того как мы их видим.

    Пример: Вам дали пачку денег и попросили составить реестр купюр. Вряд ли вы будете делать пометки прямо на банкнотах. Вы составите отдельный список. И будете в него записывать номера, номиналы и т.п. Если вас попросят рассортировать купюры по номиналу - то опять, сами купюры останутся неизменны - во просто положите их в разные кучки. И тут важно что кучка хранит в себе банкноту а не наоборот. С оборудованием тоже самое. Если вы описываете объект станок - то это объект сам по себе вес, производитель и т.п. А его включение в иерархии - это отдельно. Представьте, что у вас есть два абсолютно одинаковых станка. Тогда уместно хранить описание станка отельной таблицей. А экземпляры, где будет серийный номер, дата производства и т.п - отдельно. Если кто-то провидите ревизию станков - то это отдельный реестр кто, когда, состояние и т.п. Но сам Станок существует не зависимо от того есть о его учете запись в БД или нет.

    И пара вопросов про детали:

    Как поступаете если в объекте есть большой список? Грузите его всегда? Или делаете частично загруженные объекты? (How Incomplete DDD Aggregates Can Improve Application Performance )

    Используете ли строго типизированные ID? (How Strongly Typed IDs Can Make Your Code More Expressive)