На Хабре и не только написано приличное количество статей про Domain Driven Design — как в общем про архитектуру, так и с примерами на .Net. Но при этом зачастую слабо упоминается такая важнейшая часть этой архитектуры, как Value Objects.
В этой статье я постараюсь раскрыть нюансы реализации Value Objects в .Net Core с использованием Entity Framework Core.
Под катом много кода.
Ядром архитектуры Domain Driven Design является Домен — предметная область, к которой применяется разрабатываемое программное обеспечение. Здесь находится вся бизнес-логика приложения, которая обычно взаимодействует с различными данными. Данные могут быть двух типов:
Entity Object определяет некоторую сущность в бизнес логике и обязательно имеет идентификатор, по которому Entity можно найти или сравнить с другой Entity. Если две Entity имеют идентичный идентификатор — это одна и та же Entity. Практически всегда изменяем.
Value Object — это иммутабельный тип, значение которого задается при создании и не меняется на протяжении всей жизни объекта. Не имеет идентификатора. Если два VO структурно одинаковы — они эквивалентны.
Entity может содержать другие Entity и VO. В состав VO могут быть включены другие VO, но не Entity.
Таким образом, логика домена должна работать исключительно с Entity и VO — этим гарантируется его консистентность. Базовые типы данных, такие как string, int и т.д. зачастую не могут выступать в качестве VO, потому что могут элементарно нарушить состояние домена — что в рамках DDD является почти катастрофой.
Пример. Набивший всем оскомину в различных руководствах класс Person часто показывают вот так:
Просто и наглядно �� идентификатор, имя и возраст, где же тут можно ошибиться?
А ошибок тут может быть несколько — например, с точки зрения бизнес-логики, имя обязательно, не может быть нулевой длины или более 100 символов и не должно содержать спецсимволы, пунктуацию и т.д. А возраст не может быть меньше 10 или больше 120 лет.
С точки зрения языка программирования, 5 — вполне нормальное целое число, аналогично и пустая строка. А вот домен уже находится в некорректном состоянии.
К этому моменту мы знаем, что VO должен быть иммутабельным и содержать значение, допустимое для бизнес-логики.
Иммутабельность достигается инициализацией readonly свойства при создании объекта.
Проверка допустимости значения происходит в конструкторе (Guard clause). Саму проверку желательно сделать доступной публично — для того, чтобы, другие слои могли провалидировать данные поступившие от клиента (тот же браузер).
Давайте создадим VO для Name и Age. Дополнительно немного усложним задачу — добавим PersonalName, объединяющий в себе FirstName и LastName, и применим это к Person.
И, наконец, Person:
Таким образом, мы не можем создать Person без полного имени или возраста. Также мы не можем создать “неправильное” имя или “неправильный” возраст. А хороший программист обязательно проверит в контроллере поступившие данные с помощью методов Name.IsValid(“John”) и Age.IsValid(35) и в случае некорректных данных — сообщит об этом клиенту.
Если мы возьмем за правило везде в модели использовать только Entity и VO, то убережем себя от большого количества ошибок — неправильные данные просто не попадут в модель.
Теперь нам нужно сохранить наши данные в хранилище данных и получить их по запросу. В качестве ORM будем использовать Entity Framework Core, хранилище данных — MS SQL Server.
DDD четко определяет: Persistence — это подвид инфраструктурного слоя, поскольку скрывает в себе конкретную реализацию доступа к данным.
Домен ничего не должен знать про Persistence, по этому определяет только интерфейсы репозиториев.
А Persistence содержит в себе конкретные реализации, конфигурации маппинга, а также объект UnitOfWork.
Существует два мнения, стоит ли создавать репозитории и Unit of Work.
С одной стороны — нет, не нужно, ведь в Entity Framework Core это все уже реализовано. Если у нас многоуровневая архитектура вида DAL -> Business Logic -> Presentation, которая отталкивается от хранения данных — то почему бы и не использовать возможности EF Core напрямую.
Но домен в DDD не зависит от хранения данных и используемого ORM — это всё тонкости имплементации, которые инкапсулированы в Persistence и никого больше не интересуют. Если мы предоставляем DbContext в другие слои, то тут же раскрываем детали имплементации, намертво завязываемся на выбранную ORM и получаем DAL — как основу всей бизнес-логики, а такого быть не должно. Грубо говоря, домен не должен заметить изменение ORM и даже потерю Persistence как слоя.
Итак, интерфейс репозитория Persons, в домене:
и его реализация в Persistence:
Казалось бы, ничего сложного, но есть проблема. Entity Framework Core “из коробки” работает только с базовыми типами (string, int, DateTime и т.д.) и ничего не знает про PersonalName и Age. Давайте на��чим EF Core понимать наши Value Objects.
Для конфигурирования Entity в DDD больше всего подходит Fluent API. Атрибуты не подходят, так как домен не должен ничего знать про нюансы маппинга.
Создадим в Persistence класс с базовой конфигурацией PersonConfiguration:
и подключим его в DbContext:
Тот раздел, ради которого и написан этот материал.
В данный момент есть два более-менее удобных способа маппинга нестандартных классов к базовым типам — Value Conversions и Owned Types.
Эта фича появилась в Entity Framework Core 2.1 и позволяет определять конвертацию между двумя типами данных.
Напишем конвертер для Age (в этом разделе весь код — в PersonConfiguration):
Простой и лаконичный синтаксис, но не обошлось без недостатков:
На последнем пункте остановлюсь подробнее. Добавим в репозиторий метод, возвращающий список Person старше заданного возраста:
Здесь есть условие по возрасту, но EF Core не сможет его преобразовать в SQL запрос и, дойдя до Where(), загрузит всю таблицу в память приложения и, только потом, с помощью LINQ, выполнит условие p.Age.Value > age.Value.
В общем, Value Conversions — простой и быстрый вариант маппинга, но нужно помнить о такой особенности работы EF Core, иначе, в какой то момент, при запросе в большие таблицы, память может закончиться.
Owned Types появились в Entity Framework Core 2.0 и пришли на замену Complex Types из обычного Entity Framework.
Давайте сделаем Age как Owned Type:
Неплохо. А еще Owned Types не имеют некоторых недостатков Value Conversions, а именно пунктов 2 и 3.
2. Возможно конвертировать одно свойство в несколько колонок в таблице и наоборот
То, что нужно для PersonalName, хотя синтаксис уже немного перегружен:
3. EF Core умеет преобразовывать LINQ выражение с этим свойством в SQL запрос.
Добавим сортировку по LastName и FirstName при загрузке списка:
Такое выражение будет корректно преобразовано в SQL запрос и сортировка выполняется на стороне SQL сервера, а не в приложении.
Конечно, есть и недостатки.
С одной стороны — это совсем не то, какими должны быть Value Objects. Они не должны иметь никаких идентификаторов. VO не должны трекаться на изменения — потому как изначально иммутабельны, трекаться должны свойства родительского Entity, но не свойства VO.
С другой стороны — это такие детали реализации, которые можно опустить, но, опять же, забывать не стоит. Трекинг изменений влияет на производительность. Если с выборками единичных Entity (например, по Id) или небольших списков это не заметно, то с выборкой больших списков “тяжелых” Entity (много VO-свойств) — просадка в производительности будет весьма заметной именно из-за трекинга.
Мы разобрались как реализовать Value Objects в домене и репозитории. Пришло время все это использовать. Создадим две простейшие странички — со списком Person и формой добавления Person.
Код контроллера без Action методов выглядит так:
Добавим Action для получения списка Person:
Ничего сложного — загрузили список, создали Data-Transfer Object (PersonModel) на каждый
Person и отправили в соответствующую View.
Гораздо интереснее добавление Person:
Здесь присутствует обязательная валидация входящих данных:
Если этого не делать, то при создании VO с некорректным значением будет выкинуто ArgumentException (помним про Guard Clause в конструкторах VO). С проверкой же гораздо легче отправить пользователю сообщение, что какое то из значений неверное.
Здесь нужно сделать небольшое отступление — в Asp Net Core есть штатный способ валидации данных — с помощью атрибутов. Но в DDD такой способ валидации не является корректным по нескольким причинам:
Вернемся к AddPerson(). После валидации данных создаются PersonalName, Age, а затем и Person. Далее добавляем объект в репозиторий и сохраняем изменения (Commit). Очень важно, чтобы Commit не вызывался в репозитории EfPersons. Задача репозитория — выполнить некоторое действие с данными, не более. Commit делается только извне, когда именно — решает программист. Иначе возможна ситуация, когда в середине определенной бизнес-итерации происходит ошибка — а часть данных сохранена, а часть — нет. Получаем домен в “сломанном” состоянии. Если же Commit будет сделан в самом конце, то при ошибке транзакция просто откатится.
Я привел примеры реализации Value Objects в общем и нюансы маппинга в Entity Framework Core. Надеюсь, что материал пригодится в понимании того, как применять элементы Domain Driven Design на практике.
Полный исходный код проекта PersonsDemo — GitHub
В материале не раскрыта проблема взаимодействия с опциональными (nullable) Value Objects — если бы PersonalName или Age были не обязательными свойствами Person. Я хотел это описать в данной статье, но она и так вышла несколько перегруженной. Если есть интерес к этой проблематике — пишите в комментариях, продолжение будет.
Фанатам “красивых архитектур” в общем и Domain Driven Design в частности очень рекомендую ресурс Enterprise Craftsmanship.
Там множество полезных статей про правильное построение архитектуры и примеры реализации на .Net. Некоторые идеи были позаимствованы именно там, успешно реализованы в “боевых” проектах и частично отображены в этой статье.
Также использовалась официальная документация по Owned Types и Value Conversions.
В этой статье я постараюсь раскрыть нюансы реализации Value Objects в .Net Core с использованием Entity Framework Core.
Под катом много кода.
Немного теории
Ядром архитектуры Domain Driven Design является Домен — предметная область, к которой применяется разрабатываемое программное обеспечение. Здесь находится вся бизнес-логика приложения, которая обычно взаимодействует с различными данными. Данные могут быть двух типов:
- Entity Object
- Value Object (далее — VO)
Entity Object определяет некоторую сущность в бизнес логике и обязательно имеет идентификатор, по которому Entity можно найти или сравнить с другой Entity. Если две Entity имеют идентичный идентификатор — это одна и та же Entity. Практически всегда изменяем.
Value Object — это иммутабельный тип, значение которого задается при создании и не меняется на протяжении всей жизни объекта. Не имеет идентификатора. Если два VO структурно одинаковы — они эквивалентны.
Entity может содержать другие Entity и VO. В состав VO могут быть включены другие VO, но не Entity.
Таким образом, логика домена должна работать исключительно с Entity и VO — этим гарантируется его консистентность. Базовые типы данных, такие как string, int и т.д. зачастую не могут выступать в качестве VO, потому что могут элементарно нарушить состояние домена — что в рамках DDD является почти катастрофой.
Пример. Набивший всем оскомину в различных руководствах класс Person часто показывают вот так:
public class Person { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } }
Просто и наглядно �� идентификатор, имя и возраст, где же тут можно ошибиться?
А ошибок тут может быть несколько — например, с точки зрения бизнес-логики, имя обязательно, не может быть нулевой длины или более 100 символов и не должно содержать спецсимволы, пунктуацию и т.д. А возраст не может быть меньше 10 или больше 120 лет.
С точки зрения языка программирования, 5 — вполне нормальное целое число, аналогично и пустая строка. А вот домен уже находится в некорректном состоянии.
Переходим к практике
К этому моменту мы знаем, что VO должен быть иммутабельным и содержать значение, допустимое для бизнес-логики.
Иммутабельность достигается инициализацией readonly свойства при создании объекта.
Проверка допустимости значения происходит в конструкторе (Guard clause). Саму проверку желательно сделать доступной публично — для того, чтобы, другие слои могли провалидировать данные поступившие от клиента (тот же браузер).
Давайте создадим VO для Name и Age. Дополнительно немного усложним задачу — добавим PersonalName, объединяющий в себе FirstName и LastName, и применим это к Person.
Name
public class Name { private static readonly Regex ValidationRegex = new Regex( @"^[\p{L}\p{M}\p{N}]{1,100}\z", RegexOptions.Singleline | RegexOptions.Compiled); public Name(String value) { if (!IsValid(value)) { throw new ArgumentException("Name is not valid"); } Value = value; } public String Value { get; } public static Boolean IsValid(String value) { return !String.IsNullOrWhiteSpace(value) && ValidationRegex.IsMatch(value); } public override Boolean Equals(Object obj) { return obj is Name other && StringComparer.Ordinal.Equals(Value, other.Value); } public override Int32 GetHashCode() { return StringComparer.Ordinal.GetHashCode(Value); } }
PersonalName
public class PersonalName { protected PersonalName() { } public PersonalName(Name firstName, Name lastName) { if (firstName == null) { throw new ArgumentNullException(nameof(firstName)); } if (lastName == null) { throw new ArgumentNullException(nameof(lastName)); } FirstName = firstName; LastName = lastName; } public Name FirstName { get; } public Name LastName { get; } public String FullName => $"{FirstName} {LastName}"; public override Boolean Equals(Object obj) { return obj is PersonalName personalName && EqualityComparer<Name>.Default.Equals(FirstName, personalName.FirstName) && EqualityComparer<Name>.Default.Equals(LastName, personalName.LastName); } public override Int32 GetHashCode() { return HashCode.Combine(FirstName, LastName); } public override String ToString() { return FullName; } }
Age
public class Age { public Age(Int32 value) { if (!IsValid(value)) { throw new ArgumentException("Age is not valid"); } Value = value; } public Int32 Value { get; } public static Boolean IsValid(Int32 value) { return 10 <= value && value <= 120; } public override Boolean Equals(Object obj) { return obj is Age other && Value == other.Value; } public override Int32 GetHashCode() { return Value.GetHashCode(); } }
И, наконец, Person:
public class Person { public Person(PersonalName personalName, Age age) { if (personalName == null) { throw new ArgumentNullException(nameof(personalName)); } if (age == null) { throw new ArgumentNullException(nameof(age)); } Id = Guid.NewGuid(); PersonalName= personalName; Age = age; } public Guid Id { get; private set; } public PersonalName PersonalName{ get; set; } public Age Age { get; set; } }
Таким образом, мы не можем создать Person без полного имени или возраста. Также мы не можем создать “неправильное” имя или “неправильный” возраст. А хороший программист обязательно проверит в контроллере поступившие данные с помощью методов Name.IsValid(“John”) и Age.IsValid(35) и в случае некорректных данных — сообщит об этом клиенту.
Если мы возьмем за правило везде в модели использовать только Entity и VO, то убережем себя от большого количества ошибок — неправильные данные просто не попадут в модель.
Persistence
Теперь нам нужно сохранить наши данные в хранилище данных и получить их по запросу. В качестве ORM будем использовать Entity Framework Core, хранилище данных — MS SQL Server.
DDD четко определяет: Persistence — это подвид инфраструктурного слоя, поскольку скрывает в себе конкретную реализацию доступа к данным.
Домен ничего не должен знать про Persistence, по этому определяет только интерфейсы репозиториев.
А Persistence содержит в себе конкретные реализации, конфигурации маппинга, а также объект UnitOfWork.
Существует два мнения, стоит ли создавать репозитории и Unit of Work.
С одной стороны — нет, не нужно, ведь в Entity Framework Core это все уже реализовано. Если у нас многоуровневая архитектура вида DAL -> Business Logic -> Presentation, которая отталкивается от хранения данных — то почему бы и не использовать возможности EF Core напрямую.
Но домен в DDD не зависит от хранения данных и используемого ORM — это всё тонкости имплементации, которые инкапсулированы в Persistence и никого больше не интересуют. Если мы предоставляем DbContext в другие слои, то тут же раскрываем детали имплементации, намертво завязываемся на выбранную ORM и получаем DAL — как основу всей бизнес-логики, а такого быть не должно. Грубо говоря, домен не должен заметить изменение ORM и даже потерю Persistence как слоя.
Итак, интерфейс репозитория Persons, в домене:
public interface IPersons { Task Add(Person person); Task<IReadOnlyList<Person>> GetList(); }
и его реализация в Persistence:
public class EfPersons : IPersons { private readonly PersonsDemoContext _context; public EfPersons(UnitOfWork unitOfWork) { if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _context = unitOfWork.Context; } public async Task Add(Person person) { if (person == null) { throw new ArgumentNullException(nameof(person)); } await _context.Persons.AddAsync(person); } public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons.ToListAsync(); } }
Казалось бы, ничего сложного, но есть проблема. Entity Framework Core “из коробки” работает только с базовыми типами (string, int, DateTime и т.д.) и ничего не знает про PersonalName и Age. Давайте на��чим EF Core понимать наши Value Objects.
Configuration
Для конфигурирования Entity в DDD больше всего подходит Fluent API. Атрибуты не подходят, так как домен не должен ничего знать про нюансы маппинга.
Создадим в Persistence класс с базовой конфигурацией PersonConfiguration:
internal class PersonConfiguration : IEntityTypeConfiguration<Person> { public void Configure(EntityTypeBuilder<Person> builder) { builder.ToTable("Persons"); builder.HasKey(p => p.Id); builder.Property(p => p.Id).ValueGeneratedNever(); } }
и подключим его в DbContext:
protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfiguration(new PersonConfiguration()); }
Mapping
Тот раздел, ради которого и написан этот материал.
В данный момент есть два более-менее удобных способа маппинга нестандартных классов к базовым типам — Value Conversions и Owned Types.
Value Conversions
Эта фича появилась в Entity Framework Core 2.1 и позволяет определять конвертацию между двумя типами данных.
Напишем конвертер для Age (в этом разделе весь код — в PersonConfiguration):
var ageConverter = new ValueConverter<Age, Int32>( v => v.Value, v => new Age(v)); builder .Property(p => p.Age) .HasConversion(ageConverter) .HasColumnName("Age") .HasColumnType("int") .IsRequired();
Простой и лаконичный синтаксис, но не обошлось без недостатков:
- Невозможно конвертировать null;
- Невозможно конвертировать одно свойство в несколько колонок в таблице и наоборот;
- EF Core не умеет преобразовывать LINQ выражение с этим свойством в SQL запрос.
На последнем пункте остановлюсь подробнее. Добавим в репозиторий метод, возвращающий список Person старше заданного возраста:
public async Task<IReadOnlyList<Person>> GetOlderThan(Age age) { if (age == null) { throw new ArgumentNullException(nameof(age)); } return await _context.Persons .Where(p => p.Age.Value > age.Value) .ToListAsync(); }
Здесь есть условие по возрасту, но EF Core не сможет его преобразовать в SQL запрос и, дойдя до Where(), загрузит всю таблицу в память приложения и, только потом, с помощью LINQ, выполнит условие p.Age.Value > age.Value.
В общем, Value Conversions — простой и быстрый вариант маппинга, но нужно помнить о такой особенности работы EF Core, иначе, в какой то момент, при запросе в большие таблицы, память может закончиться.
Owned Types
Owned Types появились в Entity Framework Core 2.0 и пришли на замену Complex Types из обычного Entity Framework.
Давайте сделаем Age как Owned Type:
builder.OwnsOne(p => p.Age, a => { a.Property(u => u.Value).HasColumnName("Age"); a.Property(u => u.Value).HasColumnType("int"); a.Property(u => u.Value).IsRequired(); });
Неплохо. А еще Owned Types не имеют некоторых недостатков Value Conversions, а именно пунктов 2 и 3.
2. Возможно конвертировать одно свойство в несколько колонок в таблице и наоборот
То, что нужно для PersonalName, хотя синтаксис уже немного перегружен:
builder.OwnsOne(b => b.PersonalName, pn => { pn.OwnsOne(p => p.FirstName, fn => { fn.Property(x => x.Value).HasColumnName("FirstName"); fn.Property(x => x.Value).HasColumnType("nvarchar(100)"); fn.Property(x => x.Value).IsRequired(); }); pn.OwnsOne(p => p.LastName, ln => { ln.Property(x => x.Value).HasColumnName("LastName"); ln.Property(x => x.Value).HasColumnType("nvarchar(100)"); ln.Property(x => x.Value).IsRequired(); }); });
3. EF Core умеет преобразовывать LINQ выражение с этим свойством в SQL запрос.
Добавим сортировку по LastName и FirstName при загрузке списка:
public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons .OrderBy(p => p.PersonalName.LastName.Value) .ThenBy(p => p.PersonalName.FirstName.Value) .ToListAsync(); }
Такое выражение будет корректно преобразовано в SQL запрос и сортировка выполняется на стороне SQL сервера, а не в приложении.
Конечно, есть и недостатки.
- Никуда не делись проблемы с null;
- Поля Owned Types не могут быть readonly и должны иметь protected или private сеттер.
- Owned Types реализованы как регулярные Entity, что означает:
- У них есть идентификатор (как shadow property, т.е. он не фигурирует в доменном классе);
- EF Core трекает все изменения в Owned Types, точно так же, как и для обычных Entity.
С одной стороны — это совсем не то, какими должны быть Value Objects. Они не должны иметь никаких идентификаторов. VO не должны трекаться на изменения — потому как изначально иммутабельны, трекаться должны свойства родительского Entity, но не свойства VO.
С другой стороны — это такие детали реализации, которые можно опустить, но, опять же, забывать не стоит. Трекинг изменений влияет на производительность. Если с выборками единичных Entity (например, по Id) или небольших списков это не заметно, то с выборкой больших списков “тяжелых” Entity (много VO-свойств) — просадка в производительности будет весьма заметной именно из-за трекинга.
Presentation
Мы разобрались как реализовать Value Objects в домене и репозитории. Пришло время все это использовать. Создадим две простейшие странички — со списком Person и формой добавления Person.
Код контроллера без Action методов выглядит так:
public class HomeController : Controller { private readonly IPersons _persons; private readonly UnitOfWork _unitOfWork; public HomeController(IPersons persons, UnitOfWork unitOfWork) { if (persons == null) { throw new ArgumentNullException(nameof(persons)); } if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _persons = persons; _unitOfWork = unitOfWork; } // Actions private static PersonModel CreateModel(Person person) { return new PersonModel { FirstName = person.PersonalName.FirstName.Value, LastName = person.PersonalName.LastName.Value, Age = person.Age.Value }; } }
Добавим Action для получения списка Person:
[HttpGet] public async Task<IActionResult> Index() { var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View(result); }
View
@model PersonsListModel @{ ViewData["Title"] = "Persons List"; } <div class="text-center"> <h2 class="display-4">Persons</h2> </div> <table class="table"> <thead> <tr> <td><b>Last name</b></td> <td><b>First name</b></td> <td><b>Age</b></td> </tr> </thead> @foreach (var p in Model.Persons) { <tr> <td>@p.LastName</td> <td>@p.FirstName</td> <td>@p.Age</td> </tr> } </table>
Ничего сложного — загрузили список, создали Data-Transfer Object (PersonModel) на каждый
Person и отправили в соответствующую View.
Результат

Гораздо интереснее добавление Person:
[HttpPost] public async Task<IActionResult> AddPerson(PersonModel model) { if (model == null) { return BadRequest(); } if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); } if (!Name.IsValid(model.LastName)) { ModelState.AddModelError(nameof(model.LastName), "LastName is invalid"); } if (!Age.IsValid(model.Age)) { ModelState.AddModelError(nameof(model.Age), "Age is invalid"); } if (!ModelState.IsValid) { return View(); } var firstName = new Name(model.FirstName); var lastName = new Name(model.LastName); var person = new Person( new PersonalName(firstName, lastName), new Age(model.Age)); await _persons.Add(person); await _unitOfWork.Commit(); var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View("Index", result); }
View
@model PersonDemo.Models.PersonModel @{ ViewData["Title"] = "Add Person"; } <h2 class="display-4">Add Person</h2> <div class="row"> <div class="col-md-4"> <form asp-action="AddPerson"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <div class="form-group"> <label asp-for="FirstName" class="control-label"></label> <input asp-for="FirstName" class="form-control" /> <span asp-validation-for="FirstName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="LastName" class="control-label"></label> <input asp-for="LastName" class="form-control" /> <span asp-validation-for="LastName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Age" class="control-label"></label> <input asp-for="Age" class="form-control" /> <span asp-validation-for="Age" class="text-danger"></span> </div> <div class="form-group"> <input type="submit" value="Create" class="btn btn-primary" /> </div> </form> </div> </div> @section Scripts { @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} }
Здесь присутствует обязательная валидация входящих данных:
if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); }
Если этого не делать, то при создании VO с некорректным значением будет выкинуто ArgumentException (помним про Guard Clause в конструкторах VO). С проверкой же гораздо легче отправить пользователю сообщение, что какое то из значений неверное.
Результат

Здесь нужно сделать небольшое отступление — в Asp Net Core есть штатный способ валидации данных — с помощью атрибутов. Но в DDD такой способ валидации не является корректным по нескольким причинам:
- Возможностей атрибутов может не хватать для логики валидации;
- Любую бизнес-логику, в том числе и правила валидации параметров, устанавливает исключительно домен. У него монопольное право на это и все остальные слои должны с этим считаться. Атрибуты можно использовать, но полагаться на них не стоит. Если атрибут пропустит некорректные данные, то мы опять получим исключение при создании VO.
Вернемся к AddPerson(). После валидации данных создаются PersonalName, Age, а затем и Person. Далее добавляем объект в репозиторий и сохраняем изменения (Commit). Очень важно, чтобы Commit не вызывался в репозитории EfPersons. Задача репозитория — выполнить некоторое действие с данными, не более. Commit делается только извне, когда именно — решает программист. Иначе возможна ситуация, когда в середине определенной бизнес-итерации происходит ошибка — а часть данных сохранена, а часть — нет. Получаем домен в “сломанном” состоянии. Если же Commit будет сделан в самом конце, то при ошибке транзакция просто откатится.
Заключение
Я привел примеры реализации Value Objects в общем и нюансы маппинга в Entity Framework Core. Надеюсь, что материал пригодится в понимании того, как применять элементы Domain Driven Design на практике.
Полный исходный код проекта PersonsDemo — GitHub
В материале не раскрыта проблема взаимодействия с опциональными (nullable) Value Objects — если бы PersonalName или Age были не обязательными свойствами Person. Я хотел это описать в данной статье, но она и так вышла несколько перегруженной. Если есть интерес к этой проблематике — пишите в комментариях, продолжение будет.
Фанатам “красивых архитектур” в общем и Domain Driven Design в частности очень рекомендую ресурс Enterprise Craftsmanship.
Там множество полезных статей про правильное построение архитектуры и примеры реализации на .Net. Некоторые идеи были позаимствованы именно там, успешно реализованы в “боевых” проектах и частично отображены в этой статье.
Также использовалась официальная документация по Owned Types и Value Conversions.
