На Хабре и не только написано приличное количество статей про Domain Driven Design — как в общем про архитектуру, так и с примерами на .Net. Но при этом зачастую слабо упоминается такая важнейшая часть этой архитектуры, как Value Objects.
В этой статье я постараюсь раскрыть нюансы реализации 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.
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);
}
}
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;
}
}
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);
}
@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);
}
@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 в частности очень рекомендую этот ресурс.
Там множество полезных статей про правильное построение архитектуры и примеры реализации на .Net. Некоторые идеи были позаимствованы именно там, успешно реализованы в “боевых” проектах и частично отображены в этой статье.
Также использовалась официальная документация по Owned Types и Value Conversions.
Автор: TimurNes