Domain Driven Design: Value Objects и Entity Framework Core на практике

в 17:12, , рубрики: .net, C#, DDD, EF Core, Value Object

На Хабре и не только написано приличное количество статей про 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.

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();

Простой и лаконичный синтаксис, но не обошлось без недостатков:

  1. Невозможно конвертировать null;
  2. Невозможно конвертировать одно свойство в несколько колонок в таблице и наоборот;
  3. 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 сервера, а не в приложении.

Конечно, есть и недостатки.

  1. Никуда не делись проблемы с null;
  2. Поля Owned Types не могут быть readonly и должны иметь protected или private сеттер.
  3. 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.

Результат

Domain Driven Design: Value Objects и Entity Framework Core на практике - 1

Гораздо интереснее добавление 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). С проверкой же гораздо легче отправить пользователю сообщение, что какое то из значений неверное.

Результат

Domain Driven Design: Value Objects и Entity Framework Core на практике - 2

Здесь нужно сделать небольшое отступление — в 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js