Pull to refresh

Сущности в DDD-стиле с Entity Framework Core

Reading time 14 min
Views 36K
Original author: Jon P Smith
Эта статья о том, как применить принципы Domain-Driven Design (DDD) к классам, отображаемым Entity Framework Core (EF Core) на базу данных, и почему это может быть полезно.

TLDR


В DDD-подходе есть множество преимуществ, но главное – DDD переносит код операций создания / изменения внутрь класса сущности. Это значительно понижает шансы неверного понимания / интерпретации разработчиком правил создания, инициализации и использования экземпляров классов.

  1. В книге Эрика Эванса и его выступлениях не так много информации на этот счет:
  2. Предоставьте клиенту простую модель для получения постоянных объектов (классов) и управления их жизненным циклом.
  3. Ваши классы сущностей должны явно сообщать о том могут ли они быть изменены, как именно и по каким правилам.
  4. В DDD существует понятие агрегат. Агрегат – это дерево связанных сущностей. По правилам DDD работа с агрегатами должна осуществляться через «корень агрегации» (корневую сущность дерева).

Эрик в своих выступлениях упоминает репозитории. Я не рекомендую реализовывать репозиторий вместе с EF Core, потому что EF уже реализует паттерны «репозиторий» и «единица работы» сам по себе. Подробнее об этом я рассказываю в отдельно статье «стоит ли использовать репозиторий вместе с EF Core».

Сущности в DDD-стиле


Я начну с того, что покажу код сущностей в DDD-стиле и затем сравню их с тем как обычно создают сущности с EF Core (прим. переводчика. автор называет словом «обычно» анемичную модель»). Для примера я буду использовать базу данных интернет-магазина по продаже книг (очень упрощенную версия Амазона». Структура БД показана на изображении ниже.

image

Первые четыре таблицы представляют все что касается книг: сами книги, их авторы, обзоры. Две таблицы внизу используются в коде бизнес-логике. Эта тема подробно раскрыта в отдельной статье.
Весь код этой статьи выложен в репозиторий GenericBizRunner на GitHub. Кроме кода библиотеки GenericBizRunner там есть еще пример ASP.NET Core приложения, использующего GenericBizRunner для работы с бизнес-логикой. Больше об этом написано в статье «библиотека для работы с бизнес-логикой и Entity Framework Core».
А вот и код сущностей, соответствующий структуре БД.

public class Book
{
    public const int PromotionalTextLength = 200;
 
    public int BookId { get; private set; }          
    //… all other properties have a private set
 
    //These are the DDD aggregate propties: Reviews and AuthorLinks
    public IEnumerable<Review> Reviews => _reviews?.ToList();
    public IEnumerable<BookAuthor> AuthorsLink => _authorsLink?.ToList();
 
    //private, parameterless constructor used by EF Core
    private Book() { } 
 
    //public constructor available to developer to create a new book
    public Book(string title, string description, DateTime publishedOn, 
        string publisher, decimal price, string imageUrl, ICollection<Author> authors)
    {
        //code left out 
    }
 
    //now the methods to update the book’s properties
    public void UpdatePublishedOn(DateTime newDate)…
    public IGenericErrorHandler AddPromotion(decimal newPrice, string promotionalText)…               
    public void RemovePromotion()…
 
    //now the methods to update the book’s aggregates 
    public void AddReview(int numStars, string comment, string voterName, DbContext context)…
    public void RemoveReview(Review review)…                        
}

На что обратить внимание:

  1. Строка 5: set-доступ ко всем свойствам сущностей объявлен приватным. Это значит, что данные могут быть изменены либо с помощью конструктора, либо с помощью публичных методов, описанных ниже в этой статье.
  2. Строки 9 и 10. Связанные коллекции (те самые агрегаты из DDD) предоставляют публичный доступ к IEnumerable<T>, а не ICollection<T>. Это значит, что вы не сможете добавить или удалить элементы из коллекции напрямую. Вам придется использовать специализированные методы из класса Book.
  3. Строка 13. EF Core требует беспараметрический конструктор, но он может иметь приватный доступ. Это значит, что другой прикладной код, не сможет обойти инициализацию и создать экземпляры классов с помощью беспараметрического конструктора (прим. переводчика. Если вы конечно не создаете сущности исключительно с помощью reflection)
  4. Строки 16-20: единственный способ, с помощью которого вы сможете создать экземпляр класса Book – использовать публичный конструктор. Этот конструктор содержит всю необходимую информацию для инициализации объекта. Таким образом, объект гарантированно будет находиться в разрешенном (valid) состоянии.
  5. Строки 23-25: На этих строчках располагаются методы, позволяющие изменить состояние книги.
  6. Строки 28-29: Эти методы позволяют изменить связанные сущности (агрегаты)

Методы на строках 23-39 я буду далее называть «методы, предоставляющие доступ». Эти методы – единственный способ изменить свойства и связи внутри сущности. В сухом остатке класс Book «закрыт». Он создается через специальный конструктор и может быть изменен только частично через специальные методы с подходящими названиями. Такой подход создает резкий контраст со стандартным подходом к созданию / изменению сущностей в EF Core, в котором все сущности содержат пустой конструктор по-умолчанию и все свойства объявлены публичными. Следующий вопрос, почему первый подход лучше?

Сравнение создание сущности


Сравним код получения данных о нескольких книгах из json и создания на их основе экземпляров классов Book.

a. Стандартный подход


var price = (decimal) (bookInfoJson.saleInfoListPriceAmount ?? DefaultBookPrice)
var book = new Book
{
    Title = bookInfoJson.title,
    Description = bookInfoJson.description,
    PublishedOn = DecodePubishDate(bookInfoJson.publishedDate),
    Publisher = bookInfoJson.publisher,
    OrgPrice = price,
    ActualPrice = price,
    ImageUrl = bookInfoJson.imageLinksThumbnail
};
 
byte i = 0;
book.AuthorsLink = new List<BookAuthor>();
foreach (var author in bookInfoJson.authors)
{
    book.AuthorsLink.Add(new BookAuthor
    {
        Book = book, Author = authorDict[author], Order = i++
    });
}

b. В DDD-стиле


var authors = bookInfoJson.authors.Select(x => authorDict[x]).ToList();
var book = new Book(bookInfoJson.title, 
    bookInfoJson.description, 
    DecodePubishDate(bookInfoJson.publishedDate),
    bookInfoJson.publisher, 
    ((decimal?)bookInfoJson.saleInfoListPriceAmount) ?? DefaultBookPrice,
    bookInfoJson.imageLinksThumbnail,
    authors);

Код конструктора класса Book

public Book(string title, string description, DateTime publishedOn, 
    string publisher, decimal price, string imageUrl, 
    ICollection<Author> authors)
{
    if (string.IsNullOrWhiteSpace(title))
        throw new ArgumentNullException(nameof(title)); 
 
    Title = title;
    Description = description;
    PublishedOn = publishedOn;
    Publisher = publisher;
    ActualPrice = price;
    OrgPrice = price;
    ImageUrl = imageUrl;
    _reviews = new HashSet<Review>();       
 
    if (authors == null || !authors.Any())
        throw new ArgumentException(
        "You must have at least one Author for a book", nameof(authors));
     
    byte order = 0;
    _authorsLink = new HashSet<BookAuthor>(
        authors.Select(a => new BookAuthor(this, a, order++)));
}

На что обратить внимание:

  1. Строки 1-2: конструктор заставляет вас передать все необходимые для правильной инициализации данные.
  2. Строки 5, 6 и 17-9: код содержит несколько проверок бизнес-правил. В данном конкретном случае нарушение правил рассматривается как ошибка в коде, поэтому в случае нарушения будет выброшено исключение. Если бы пользователь мог исправить эти ошибки, возможно, я бы использовал статическую фабрику, возвращающую Status<T> (прим. переводчика. Я бы использовал Option<T> или Result<T>, как более широко-употребимое название). Status – это тип, возвращающий список ошибок.
  3. Строки 21-23: Связь BookAuthor создается в конструкторе. Конструктор BookAuthor может быть объявлен с уровнем доступа internal. Таким образом мы сможем предотвратить создание связей вне DAL.

Как вы могли заметить, объем кода для создания сущности в обоих случаях примерно одинаков. Так почему же DDD-стиль лучше? Стиль DDD лучше тем, что:

  1. Контролирует доступ. Случайное изменение свойства исключено. Любое изменение происходит через конструктор или публичный метод с соответствующим названием. Совершенно очевидно, что происходит.
  2. Соответствует DRY (don’t repeat yourself). Вам может потребоваться создавать экземпляры Book в нескольких местах. Код присвоения находится в конструкторе и вам не придется повторять его в нескольких местах.
  3. Скрывает сложность. В классе Book есть два свойства: ActualPrice и OrgPrice. Оба этих значения должны быть равны при создании новой книги. В стандартном подходе каждый разработчик должен знать об этом. В DDD-подходе достаточно, чтобы об этом знал разработчик класса Book. Остальные узнают об этом правиле, потому что оно явным образом записано в конструкторе.
  4. Скрывает создание агрегата. В стандартном подходе разработчик должен вручную создать экземпляр BookAuthor. В DDD-стиле эта сложность инкапсулирована для вызывающего кода.
  5. Позволяет свойствам иметь приватный доступ на запись
  6. Одна из причин использования DDD – «запереть» (lock down) сущности, т.е. не давать возможности изменять свойства напрямую. Давайте сравним операцию изменения с использованием DDD и без.

Сравнение изменения свойств


Одним из главных преимуществ сущностей в стиле DDD Эрик Эванс называет следующее: «Они явно описывают правила доступа к объекту» (They communicate design decisions about object access).
Прим. переводчика. Оригинальная фраза труднопереводима на русский язык. В данном случае design decisions – это принятые решения о том, как ПО должно работать. Имеется в виду, что решения обсуждались и подтверждены. Код с конструкторами, корректно инициализирующими сущности и методами с корректными названиями, отражающими смысл операций явным образом сообщает разработчику о том, что присвоения определенных значений сделаны с умыслом, а не по ошибке и не являются прихотью другого разработчика или деталями реализации.
Я понимаю эту фразу следующим образом.

  1. Сделайте очевидным, как изменять данные внутри сущности и какие данные должны изменяться вместе.
  2. Сделайте очевидным когда вы не должны изменять определенные данные в сущности.
Давайте сравним два подхода. Первый пример – простой, а второй – посложнее.

1. Изменение даты публикации


Допустим, мы хотим сначала поработать с черновиком книги и лишь затем опубликовать. В момент создания черновика устанавливается ориентировочная дата публикации, которая весьма вероятно, будет изменена в процессе редактирования. Для хранения даты публикации будем использовать свойство PublishedOn.

a. Сущность с публичными свойствами


var book = context.Find<Book>(dto.BookId);
book.PublishedOn = dto.PublishedOn;        
context.SaveChanges();                    

b. Сущность в DDD-стиле


В DDD-стиле setter свойства объявлен приватным, поэтому мы будем использовать специализированный метод доступа.

var book = context.Find<Book>(dto.BookId);
book.UpdatePublishedOn( dto.PublishedOn);        
context.SaveChanges();                    

Эти два случая почти не отличаются. DDD-вариант даже немного длиннее. Но разница все-же есть. В DDD-стиле вы точно знаете, что дата публикации может быть изменена, потому что существует метод с очевидным названием. Вы также знаете, что вы не можете изменить издателя, потому что для свойства Publisher нет соответствующего метода для изменения. Эта информация будет полезна любому программисту, работающего с классом книги.

2. Управление скидкой для книги


Другое требование — мы должны иметь возможность управлять скидками. Скидка состоит из новой цены и комментария, например «50% до конца этой недели!»

Реализация этого правила простая, но не слишком очевидная.

  1. Свойство OrgPrice — это цена без учета скидки.
  2. ActualPrice — текущая цена, по которой продается книга. Если скидка действует, то текущая цена будет отличаться от OrgPrice на размер скидки. Если нет, то значение свойств будет равно.
  3. Свойство PromotionText должно содержать текст скидки, если скидка применена или null, если в данный момент скидка не применяется.

Правила довольно очевидны для того, кто их реализовывал. Однако для другого разработчика, скажем, разрабатывающего UI для добавления скидки. Добавление методов AddPromotion и RemovePromotion в класс сущности скрывает детали реализации. Теперь у другого разработчика есть публичные методы с соответствующими названиями. Семантика использования методов — очевидна.

Взглянем на реализацию методов AddPromotion и RemovePromotion.

public IGenericErrorHandler AddPromotion(decimal newPrice, string promotionalText)
{
    var status = new GenericErrorHandler();
    if (string.IsNullOrWhiteSpace(promotionalText))
    {
        status.AddError(
            "You must provide some text to go with the promotion.",
             nameof(PromotionalText));
        return status;
    }
 
    ActualPrice = newPrice;  
    PromotionalText = promotionalText; 
    return status; 
}

На что обратить внимание:

  1. Строки 4 -10: добавление комментария PromotionalText обязательно. Метод проверяет, что текст не пустой. Т.к. Эту ошибку пользователь может исправить метод возвращает список ошибок для исправления.
  2. Строки 12, 13: метод устанавливает значения свойств в соответствие с реализацией, которую выбрал разработчик. Пользователю метода AddPromotion не обязательно знать их. Чтобы добавить скидку достаточно написать просто:

var book = context.Find<Book>(dto.BookId);
var status = book.AddPromotion(newPrice, promotionText);        
if (!status.HasErrors) 
   context.SaveChanges();
return status;

Метод RemovePromotion гораздо проще: он не предполагает обработки ошибок. Поэтому возвращаемое значение просто void.

public void RemovePromotion()
{
    ActualPrice = OrgPrice; 
    PromotionalText = null; 
}

Эти два примера сильно отличаются друг от друга. В первом примере изменение свойства PublishOn на столько простое, что стандартная реализация вполне подходит. Во втором примере детали реализации не очевидны для того, кто не работал с классом Book. Во втором случае DDD-стиль со специализированными методами доступа скрывает детали реализации и делает жизнь других разработчиков проще. Также, во втором примере код содержит бизнес-логику. Пока объём логики небольшой мы можем хранить ее прямо в методах доступа и возвращать список ошибок, если метод используется не правильно.

3. Работа с агрегатом – свойство-коллекция Reviews


DDD предлагает работать с агрегатом только через корень. В нашем случае свойство Reviews создает проблемы. Даже если setter будет объявлен приватным, разработчик все-равно может добавить или удалить объекты с помощью методов add и remove или даже вызвать метод clear, чтобы очистить коллекцию целиком. Здесь нам поможет новая функция EF Core — backing fields.

Backing field позволяет разработчику инкапсулировать настоящую коллекцию и предоставить публичный доступ к интерфейсной ссылке IEnumerable<T>. Интерфейс IEnumerable<T> не предоставляет методов add, remove или clear. В коде ниже пример использования backing fields.

public class Book
{
    private HashSet<Review> _reviews;
    public IEnumerable<Review> Reviews => _reviews?.ToList();
     //… rest of code not shown
}

Чтобы это сработало нужно рассказать EF Core что при чтении из бд нужно записывать в приватное поле, а не публичное свойство. Код конфигурации показан ниже.

protected override void OnModelCreating
    (ModelBuilder modelBuilder)
{ 
     modelBuilder.Entity<Book>()
        .FindNavigation(nameof(Book.Reviews))
        .SetPropertyAccessMode(PropertyAccessMode.Field);
    //… other non-review configurations left out
}

Для работы с обзорами я добавил два метода: AddReview и RemoveReview классу книги. Метод AddReview более интересен. Вот его код:

public void AddReview(int numStars, string comment, string voterName, 
    DbContext context = null) 
{
    if (_reviews != null)    
    {
        _reviews.Add(new Review(numStars, comment, voterName));   
    }
    else if (context == null)
    {
        throw new ArgumentNullException(nameof(context), 
            "You must provide a context if the Reviews collection isn't valid.");
    }
    else if (context.Entry(this).IsKeySet)  
    {
        context.Add(new Review(numStars, comment, voterName, BookId));
    }
    else                                    
    {                                        
        throw new InvalidOperationException("Could not add a new review.");  
    }
}

На что обратить внимание:

  1. Строки 4-7: я намеренно не инициализирую поле _reviews в приватном беспараметрическом конструкторе, который EF Core использует, когда загружает сущности из БД. Это позволяет моему коду определить была ли загружена коллекция с помощью метода .Include(p => p.Reviews). В публичном конструкторе я инициализирую поле, так что NRE при работе с созданной сущностью не произойдет.
  2. Строки 8-12: Если коллекция Reviews не была загружена код должен использовать DbContext для инициализации.
  3. Строки 13-16: Если книга была успешно создана и содержит ID, то я использую другую технику добавления обзора: просто устанавливаю foreign key в экземпляре класса Review и записываю в БД. Более подробно об этом написано в секции 3.4.5 моей книги.
  4. Строка 19: Если мы оказались здесь, то есть какая-то проблема с логикой кода. Поэтому я выбрасываю исключение.

Я спроектировал все мои методы доступа для обратил случая, когда загружена только корневая сущность. Как обновить агрегат остается на усмотрение методов. Возможно потребуется загрузка дополнительных сущностей.

Заключение


Чтобы создавать сущности в DDD-стиле с EF Core необходимо придерживаться следующих правил:

  1. Создавайте публичные конструкторы для создания корректно инициализированных экземпляров классов. Если в процессе создания могут произойти ошибки, которые пользователь может исправить, создавайте объект не с помощью публичного конструктора, а с помощью фабричного метода, возвращающего Status<T>, где T – тип создаваемой сущности
  2. Все свойства setter’ы свойств приватные. Т.е. все свойства – read-only за пределами класса.
  3. Для навигационных свойств коллекций объявляйте backing fields, а тип публичного свойства объявляйте IEnumerable<T>. Это не позволит другим разработчикам неконтролируемо изменять коллекции
  4. Вместо публичных setter’ов создавайте публичные методы для всех разрешенных операций изменения объекта. Эти методы должны возвращать void, если операция не может завершиться ошибкой, которую пользователь может исправить или Status<T> — если может.
  5. Рамки ответственности сущности имеет значение. Я думаю, что лучше всего ограничить сущности изменением самого класса и других классов внутри агрегата, но не за пределами. Правила валидации должны быть ограничены проверками правильности создания и изменения состояния сущностей. Т.е. я не проверяю такие бизнес-правила, как остатки на складе. Для этого существует специальный код бизнес-логики.
  6. Методы, изменяющие состояния должны предполагать, что загружен только корень агрегации. Если методу требуется загрузка других данных, он должен позаботиться об этом самостоятельно.
  7. Методы, изменяющие состояния должны предполагать, что загружен только корень агрегации. Если методу требуется загрузка других данных, он должен позаботиться об этом самостоятельно. Такой подход упрощает использование сущностей другими разработчиками.

Плюсы и минусы DDD-сущностей при работе с EF Core


Мне нравится критический подход к любому паттерну или архитектуре. Вот, что я думаю по поводу использования DDD-сущностей.

Плюсы


  1. Использование специализированных методов для изменения состояния – более чистый подход. Это точно хорошее решение, просто потому, что правильно-названные методы гораздо лучше раскрывают намерения кода и делают очевидным, что можно менять, а что нет. Кроме того методы могут вернуть список ошибок, если пользователь может их исправить.
  2. Изменение агрегатов только через корень тоже хорошо работает
  3. Детали реализации связи один-ко-многим между классами Book и Review теперь скрыты для пользователя. Инкапсуляция – это базовый принцип ООП.
  4. Использование специализированных конструкторов позволяет убедиться в том, что сущности созданы и гарантировано корректно инициализированы
  5. Перемещение кода инициализации в конструктор значительно снижает вероятность того, что разработчик не верно интерпретирует то, как класс должен быть инициализирован.

Минусы


  1. Мой подход содержит зависимости от реализации EF Core.
  2. Некоторые люди даже называют это анти-паттерном. Проблема в том, что теперь сущности предметной модели зависят от кода доступа к базе данных. В терминах DDD – это плохо. Я осознал, что если бы я не сделал этого, то мне бы пришлось полагаться на то, что вызывающий код знает о том, что должно быть загружено. Такой подход ломает принцип разделения ответственности (separation of concerns).
  3. DDD заставляет писать больше кода.

Действительно ли это стоит того в простых случаях, вроде обновления даты публикации книги?
Как вы могли заметить мне нравится DDD-подход. Однако, мне потребовалось какое-то время, чтобы правильно его структурировать, но на данный момент подход уже устаканился и я применяю его в проектах, над которыми работаю. Я уже успел попробовать этот стиль а небольших проектах и доволен, но все плюсы и минусы еще предстоит узнать, когда я применю его в больших проектах.

Мое решение разрешить использовать код, специфичный для EFCore в аргументах методов сущностей предметной модели было не простым. Я пытался не допустить этого, но в итоге пришел к тому, что вызывающему коду приходилось загружать множество навигационных свойств. И если этого не сделать, то изменение просто не будет применено без каких-либо ошибок (особенно в отношениях один-к-одному). Это было для меня не приемлемо, поэтому я разрешил использование EF Core внутри некоторых методов (но не конструкторов).

Другая плохая сторона в том, что DDD заставляет писать значительно больше кода для CRUD-операций. Я до сих пор не уверен, следует ли продолжать есть кактус и писать отдельные методы для всех свойств или же в некоторых случаях стоит отойти от столь радикального пуританства. Я знаю, что есть просто вагон и маленькая тележка скучного CRUD’а, который проще написать напрямую. Только работа над реальными проектам покажет, что лучше.

Другие аспекты DDD, не раскрытые в этой статье


Статья итак получилось чересчур длинной, поэтому я собираюсь закончить здесь. Но, это значит, что есть еще много нераскрытого материла. О чем-то я уже писал, о чем-то я буду писать в ближайшем будущем. Вот, что осталось за бортом:

  1. Бизнес-логика и DDD. Я использую концепции DDD в коде бизнес-логики уже несколько лет и с использованием новых возможностей EF Core ожидаю, что смогу перенести часть логики в код сущностей. Прочитайте статью «Снова об архитектуре слоя бизнес-логики с Entity Framework (Core и v6)»
  2. DDD и паттерн «репозиторий». Эрик Эванс рекомендует использовать репозиторий для того, чтобы абстрагировать доступ к данным. Я пришел к выводу, что использование паттерна «репозиторий» вместе с EF Core – плохая затея. Почему? Читайте в той-же самой статье.
  3. Несколько DBContext’ов / ограниченных контекстов (bounded contexts). Я долго думал о разделении базы данных на несколько DbContext’ов. Например, создать отдельный BookContext для работы только с классом Book и его агрегатом и еще один отдельный OrderContext, для обработки заказов. Думаю, идея «ограниченных контекстов» очень важна, особенно масштабирования приложений по мере роста. Пока я еще не выделили паттерна для этой задачи, но ожидаю написать статью на эту тему в будущем.

Весь код этой статьи доступен в репозитории GenericBizRunner на GitHub. Этот репозиторий содержит пример приложения ASP.NET Core со специализированными методами доступа для изменения класса Book. Вы можете клонировать репозиторий и запустить приложение локально. Оно использует in-memory Sqlite в качестве базы данных, так что должно запуститься на любой инфраструктуре.

Счастливой разработки!
Tags:
Hubs:
+7
Comments 17
Comments Comments 17

Articles