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



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

Результат


Гораздо интереснее добавление 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.

Вы можете помочь и перевести немного средств на развитие сайта

Теги:



Комментарии (22):

  1. alexs0ff
    /#19885662

    PersonModel->Person

    А может пойти дальше и сделать что-то типа PersonStored чисто модель для EF или другой системы ORM? Тогда не нужно будет создавать костыли в виде Value Conversions и т.д., а сделать многое декларативно — на атрибутах.

    • TimurNes
      /#19885768

      Конечно, можно. Но не нужно.
      Во первых, атрибуты — это такая себе штука в DDD. Чем меньше домен замусорен атрибутами (тем более ориентированными на инфраструктурный слой), тем лучше. Но в некоторых случаях без них не обойтись — нет идеальных ЯП, библиотек и ORM.
      Во вторых — две мощнейшие ORM в .Net Core, EF Core и NHibernate, многое полезное для DDD умеют «из коробки». Хотя NHibernate в плане удобства для DDD явно выигрывает у EF Core, но EF Core активно развивается. И в этом плане я не считаю Value Conversions костылем. В обычном EF все гораздо хуже.
      В третьих — нужно еще оценить затраты времени на создание такого proxy-слоя между доменом и ORM. Опять же, EF Core хорошо шагнул вперед и постоянно развивается, а NHibernate в этом плане давно все умеет без хитрых костылей

      • alexs0ff
        /#19885878

        Чем меньше домен замусорен атрибутами

        А причем тут домен? Не нужно вводить инфраструктуру в домен. EF или Nhibernate, а может и простая сериализация в файл не должна влиять на предметную область. Просто доменные VO переводим в объекты инфраструктуры (могут быть классы или байты) и отдаем в соответствующий слой. Все четко и понятно без нарушения границ.

        • TimurNes
          /#19885956

          Вот неплохая статья на эту тему, хотя тут частично про EF vs NHibernate

          Having the domain model separated from the persistence model

          ИМХО, но DDD очень сложен в 100% имплементации на любом из языков программирования. И мы, как разработчики, все равно будем ориентироваться на выбранную ORM или сериализацию, хотя бы с целью оптимизации времени на разработку. DDD сам по себе добавляет весьма заметный оверхед на разработку

  2. sstv
    /#19885770

    Хочу дополнить по Owned types. К сожалению они ещё далеки от идеала. Они не могут быть Null, и их всегда нужно создавать (можно со значениями свойств по умолчанию, но это уже нарушение DDD, так как VO в некорректном состоянии).
    Кроме того, EntryState у Owned Type в трекере изменений должен совпадать с его владельцем. К примеру если Entity имеет state modified, то и все его Owned Type тоже должны быть modified и наоборот. Это создает некоторые неудобства и костыли.

    • TimurNes
      /#19885808

      Да про null я уже упомянул.
      А EntryState у Owned Type — не думаю, что это может быть проблемой, поскольку VO в принципе не могут быть изменены.
      Если мы хотим изменить VO/Owned Type, то мы должны создать новый VO. На примере Person и PersonalName это будет примерно так:

      var firstName = new Name(model.FirstName);
      var lastName = new Name(model.LastName);
      person.PersonalName = new PersonalName(firstName, lastName);

      Никаких проблем или костылей я тут не вижу

      • sstv
        /#19886906

        К примеру мы достали из базы данных сущность Person, у него указан какой-то адрес (VO). Мы создали новый инстанс адреса и присвоили его к Person. У Person EntryState == modified, а у адреса он становится Added, потому что он новый. И в подобных случаях необходимо вручную указывать что на самом-то деле адрес не новый, а обновленный. Либо предусмотреть возможность полного обновления VO на основе другого VO через какой-нибудь метод)

  3. euroUK
    /#19886656

    Вы меня конечно простите, но использование DDD в реальных проектах не работает. Все что я видел, это примеры уровня HelloWorld, которые разбиваются при попытке создать реальную систему. В паре компаний где я работал, были попытки использовать DDD что выливалось в тонны однообразного кода и одинаковых методов.

    1) Приведите мне банальный сценарий, как вы будете реализовывать смену фамилии? Допустим у нас в CRM девушка замуж вышла? А еще бы надо прикрутить смену возраста например, так как пользователь мог внести неправильно данные.

    2) Другой сценарий. Нам нужно отдать список клиентов отфильтровав по одному из полей. Где будет находиться эта логика? Должен ли репозиторий знать о специфичных вариантах использования? Судя по тому, что у вас репозиторий возвращает лист, вы предполагаете, что все методы манипулирующие данными будут в нем. Хорошо. А что тогда мы будем делать, когда нам потребуется для разных подсистем давать доступ только к определенным методам? Будем создавать несколько типов репозиториев для одного домена?

    3) В приведенном выше коде контроллера имеет место быть каша. Мы почему-то должны знать в нем, что существует Name, Age у Person. В нормальном мире такое обычно выносят в отдельный сервис, но тут DDD так что куда это девать я не знаю.

    4) Я абсолютно не понимаю, почему люди с завидным упорством продолжают тыкать в .Net Core UnitOfWork и репозитории, когда DbContext и DbSet предоставляют всю необходимую логику и требуемый уровень абстракции. Свои велосипеды приводят лишь к повторению механических действий по бесконечному созданию репозиториев. Конечно, можно настроить кодогенератор, но в любом мы получаем кучу одинаковых на 90% классов.

    Поэтому выскажу мое личное мнение. По-моему, не стоит предлагать красивые архитектуры, если вы на 100% не уверены, что они работают в реальном мире.

    • TimurNes
      /#19888146

      Если DDD не работает в ваших проектах — это не означает, что DDD не работает совсем.
      У меня был не один проект с DDD, все работает отлично и продолжает развиваться. DDD — штука сложная и понимать это должна вся команда, а не один тимлид. Возможно, по этому у вас DDD и не работает.

      1. Допустим, что имя не должно именяться, только фамилия
        Person.cs
        public class Person {
            
            ...
            
            public PersonalName PersonalName { get; private set; }
        
            public void UpdateLastName(Name lastName) {
                if (lastName == null) {
                    throw new ArgumentNullException(nameof(lastName));
                }
        
                this.PersonalName = new PersonalName(
                    this.PersonalName.FirstName, 
                    lastName);
            }
        }

    • elepner
      /#19896800

      Абсолютно соглашусь с вашим комментарием. На мой взгляд статья очень поверхностная и ушла на полшага вперед от пресловутого примера Blogs/Posts, а может и даже вредная. Вся эта мишура рассыпается, как только нужно сделать безнес транзакцию между несколькими доменами. Например, навестить админа, что кто-то сменил фамилию. Или добавить троттлинг на чтение пользователей.
      Добавлю от себя еще 5 копеек. Если проект не очень сложный, но есть желание поиграть в ДДД, то в первую очередь нужно создавать доменные DbContext. Далее следует сменить, так сказать, mindset и вместо вызовов методов нужно посылать сообщения между подсистемами. Для этого советую воспользоваться библиотекой MediatR для начинающих или GreenPipes, если охота больше хардкора. Для более сложных проектов нужно переходить к message broker'ам. Тут поможет MassTransit или NServiceBus.
      Еще интересует, зачем вы смешали валидацию и VO? Посмотрите насколько убог код вашего контроллера. 3 одинаковых if. А что если я добавлю еще одно правило? Мне надо не забыть изменить еще код контроллера? Для этих целей советую изучить библиотеку FluentValidation.
      Если хотите узнать, про настоящее DDD советую доклады Jimmy Bogard'a. А также must see вот это видео, где подробнее раскрыты тезисы из моего комментария.
      На мой скромный взгляд, DDD это вообще не про EF и не про UoW в EF зло или нет (зло). Это все слишком низкоуровневые вещи. DDD — это про правильное разделение приложения на Bounded Context, а также разработка протоколов передачи сообщений между этими самыми Bounded Context.

  4. Roman_Ryzhiy
    /#19887078

    «Атрибуты не подходят, так как домен не должен ничего знать про нюансы маппинга.» — а разве ваши валидации — это нюансы маппинга? Чем Range и MaxLength не подошли?

    • TimurNes
      /#19887104

      «нюансы маппинга» — не совсем корректно написал, скорее «нюансы ограничений при создании объектов».
      Presentation layer не должен брать на себя ответственность устанавливать правила валидации. Его задача — провалидировать данные, т.е. спросить домен, нравится ли ему такое имя/возраст или нет, и если нет — сообщить об ошибке. Потому что Name/Age — это доменные объекты и только домену решать, подходит ли ему Age 25 или Name «Alex»

      • Roman_Ryzhiy
        /#19887146

        Почему бы не отдать всё на откуп домену?

        public class Person
        {
        public int Id { get; set; }

        [MaxLength(100), ErrorMessage = «Недопустимое имя»)]
        public string Name { get; private set или как кому нравится }

        [Range(5, 100, ErrorMessage = «Недопустимый возраст»)]
        public int Age { get; private set или как кому нравится }
        }

        • TimurNes
          /#19887646

          1. Name и Age (как VO) — это доменные объекты и могут быть (и наверняка будут) переиспользованы в других Entity. Для всех будете атрибуты писать и потом везде исправлять, если вдруг требования поменяются?
          2. Value Objects — не обязательно должны быть только частью Entity. В домене они могут использоваться и сами по себе.
          3. Атрибуты никак не помешают мне сделать так:
            person.Age = -25;
            person.Name = "%_!&?"

            И успешно сохранить это в БД. А все потому, что это атрибуты валидации и должны использоваться в DTO (PersonModel в нашем случае).
            System.ComponentModel.DataAnnotations Namespace
            The System.ComponentModel.DataAnnotations namespace provides attribute classes that are used to define metadata for ASP.NET MVC and ASP.NET data controls.
            Т.е. это Presentation layer, а Entity — это доменный объект, а не DTO.
            VO же полностью исключают возможность передачи некорректных данных в домен.

          Ну и, ваш Person, если убрать private set — это обычная анемичная модель, со всеми ее преимуществами и недостатками

          • Roman_Ryzhiy
            /#19888180

            «Атрибуты никак не помешают мне сделать так… person.Age = -25;… И успешно сохранить это в БД» — пятница принимается в качестве оправдания. Попробуйте. Просто попробуйте.

  5. Adler
    /#19888164

    То же самое спокойно реализуется таким образом:

    public class Person
    {
        public Person(string name, int age)
        {
            SetName(name);
            SetAge(age);
        }
    
        public int Id { get; private set; }
        public string Name { get; private set; }
        public int Age { get; private set; }
    
        public bool SetName(string newName)
        {
            // validation rules here
        }
    
        public bool SetAge(int newAge)
        {
            // validation rules here
        }
    }
    


    При работе с ORM можно добавить конструктор «protected Person()», поменять private на protected, добавить virtual, и т.д. Это уже выбор между прагматичностью и «идеальной» архитектурой.

    И насчет ValueObjects, я не знаю, откуда взято то, что они immutable. Идем к первоисточникам (Domain Driven Design by Eric Evans):

    An object that represents a descriptive aspect of the domain with no conceptual identity is called a VALUE OBJECT. VALUE OBJECTS are instantiated to represent elements of the design that we care about only for what they are, not who or which they are.


    И хороший пример, который показывает, что все зависит от контекста приложения:

    Is “Address” a VALUE OBJECT? Who's Asking?
    • In software for a mail-order company, an address is needed to confirm the credit card, and to address the parcel. But if a roommate also orders from the same company, it is not important to realize they are in the same location. Address is a VALUE OBJECT.
    • In software for the postal service, intended to organize delivery routes, the country could be formed into a hierarchy of regions, cities, postal zones, and blocks, terminating in individual addresses. These address objects would derive their zip code from their parent in the hierarchy, and if the postal service decided to reassign postal zones, all the addresses within would go along for the ride. Here, Address is an ENTITY.
    • In software for an electric utility company, an address corresponds to a destination for the company's lines and service. If roommates each called to order electrical service, the company would need to realize it. Address is an ENTITY. Alternatively, the model could associate utility service with a “dwelling,” an ENTITY with an attribute of address. Then Address would be a VALUE OBJECT.

    • TimurNes
      /#19888654

      Для каждого свойства каждой Entity будете создавать методы-сеттеры и копи-пастить правила валидации? Зачем? Чтоб написать кучу одинакового кода и потом искать во всему проекту, если Name со 100 увеличится до 150 символов? В Entity валидация данных — явно лишняя. Валидация логики — да, но не данных.
      И другое — если среди кода я вижу Name, то я точно знаю, что это Name, а не PhoneNumber или EmailAddress, хотя формально все они — строки. А Age — это именно возраст, а не рост в сантиметрах.
      В этом методе программист элементарно может перепутать местами параметры:

      void SomeMethod(string name, string phoneNumber);

      А в этом перепутать не получится при всем желании:
      void SomeMethod(Name name, PhoneNumber phoneNumber);

      И насчет ValueObjects, я не знаю, откуда взято то, что они immutable.
      Да, их не обязательно делать immutable. На приктике даже само название — Value Object — говорит о том, что это некое значение. Новое значение — новый Value Object.

      Еще дополню, что мы можем передать, скажем, номер телефона, в доменный сервис и совершать с ним какие либо манипуляции без привлечения Entity. Если это string — как сервис может быть уверен, что значение корректно, а не какая то ерунда по типу «qwerty$%^»? С VO PhoneNumber такого в принципе не может случится

      • Adler
        /#19888826

        Копипаст — это зло, тут я с вами соглашусь. Есть много методов не копипастить — выносить код можно разными способами, один из которых — то, что вы описали.

        В чем я с вами не соглашусь, так это что валидация логики и данных — это разные вещи. Я смотрю на это как на логику домена (тот факт, что возраст может быть между 10 и 120 годами адекватен в рамках данного конкретного домена, но не типа Age в общем).

        То, что легко перепутать местами параметры, тут я тоже соглашусь. В последнее время я часто задаю имена параметров явно — это решает проблему и имхо это более прагматичный подход, чем создавать объект каждый раз. Если вы посмотрите на swift, то там это стандартный подход и он нужен для читабельности (первых попавшийся пример из интернета):

        repeatThis("swift", andDoItThisManyTimes: 3)


        И сложно согласиться с аргументом по поводу Value Object — я привел цитату Eric Evans из первоисточника по DDD. Что в этой цитате наводит вас на сделанные выводы о поведении Value Object, кроме названия?

  6. euroUK
    /#19888714

    Я не говорю, что DDD нельзя использовать. Однако реальные бизнес приложения с DDD требуют гигантсктого оверхеда. И то, что вы написали выше лишь подтверждает это.

    1) Представьте, что в вашем домене 40 свойств. Вы не можете напрямую их изменять из ваших сервисов, а значит у вас будет еше 40 методов на изменение этих свойст. Получается, чтобы просто поменять 5 свойств из модели, мы должны сделать 5 вызовов.

    2) Касаемо логики фильтрации. Представьте, что у вас два десятка полей, по которым можно фильтровать. Скорее всего, вы захотите использовать какой-нибудь экспрешион, а не писать 20! методов. Сразу возникает вопрос, где будет происходить его формирование в случае DDD.

    3) Права доступа. Пример. Админ может менять паспортные данные, клиент — нет. Желательно, чтобы этих методов команда разработки клиентов просто не видела. Сейчас я могу сделать AbcAdminService.dll и AbcClientService.dll. Они будут использовать одинаковую логику доступа к данным через AbcDataService и одинаковые модели, но доступа к чужим методам не будет. В больших проектах это крайне полезно, ибо десятки похожих методов просто путают разработчиков и усложняют разработку.

    4) Да, я имел ввиду, что на уровне контроллера вы должны знать только Presentation модель. Да, вы правильно отметили, что нужен сервис AbcModelBuilder. Вопрос, почему мы тогда не можем использовать хотя бы AbcDataService, инкапсулирующего логику создания expression для запросов и прочие однотипные операции? Я веду к тому, что сервисная модель гораздо лучше вливается в реальные проекты.

    5) Мы не должны раскрывать детали реализации Persistence. Да EntityFramework и скрывает от вас реализацию Persistence. Вы можете использовать кучу конкретных провайдеров, хоть в память, а хоть и свой для сохранения в файл напишите. Когда вы используете DbContext, вы в общем случае понятия не имеете, что там у вас используется.

    6) throw new ArgumentNullException(nameof(persons));
    Никогда. Нет, не так. НИКОГДА так не делайте для бизнес логики. Просто померяйте скорость обработки исключений. Вместо 20к запросов в секунду у вас будет 200.

    • Adler
      /#19888986

      DDD отчасти диктует более логичную структуру приложения, где бизнес логика находится на по возможности на самых низких слоях. То есть определенный код будет в любом случае присутствовать, вопрос только — где именно.

      1. Не совсем. Определенные свойства меняются одновременно и являются более целостной операцией. В таком случае может быть один метод, который меняет сразу N свойств, причем после этого метода объект остается целостным. Для некоторых свойств я даже не создаю методы, как раз по причине оверхеда — в случае такой необходимости будет достаточно легко зарефакторить код.

      2. Для этого есть Queries, как говорится не репозиторием единым…

      3. В таких случаях можно делать определенные методы internal и доступными только сервисам, а методы сервисов, которые проверяют права доступа — доступными извне. Опять же, код тот же, просто немного по-другому структурирован.

      5. Я не сильный фанат EntityFramework, но DbContext уже реализует паттерн UnitOfWork и большинство ORM поддерживает TransactionScope. Одна из идей репозитория в DDD, что он сохраняет Aggregation Root, то есть UnitOfWork мало нужен. Для случаев, когда нужно сохранить несколько Aggregation Root, есть Transaction Scope и UnitOfWork тоже мало нужен.

      6. Это зло на продакшене, но такие ошибки должны всплывать раньше и на проде такой код теоретически даже не будет вызываться. Это просто способ не думать о том, откуда пришел NullPointer :)

    • TimurNes
      /#19889156

      1. Если свойство доступно для изменения, то это будет просто SomeProperty { get; set; } и доступно всем. Никаких методов для этого не нужно. Отдельный метод нужен только, если на изменение свойства завязана какая нибуть логика. Скорее всего это будет доменный сервис или метод в Entity (в простых случаях) и сеттер становится private или internal.
      2. Ничего не мешает передать фильтры в Repository и конкретный запрос с фильтрами формировать уже там. С IQueryable это очень просто.
      3. Логика изменения паспортных данных выносится в доменный сервис, которым пользуются только админы. Ограничить можно разными сборками (доменные сервисы не обязательно должны быть в одной сборке с Entity), но если это один VS Solution — программисты клиентской части все равно будут видеть этот сервис.
      Code Review, кстати, никто не отменял.
      4. Если я вас правильно понял, то AbcDataService — это PersonRepository. Репозиторий — это же обычный сервис, только находится в Persistence и отвечает за сохранение и выборку данных, за что и получил отдельное название.
      5. Entity Framework — это уже раскрытие реализации. В проекте в дополнение может использоваться легковесный Dapper для быстрой выборки, могут быть чистые SQL запросы через ADO.Net, загрузка из csv файлов, что угодно — и все это в одном проекте. Persistence — это черный ящик, и что там внутри — остальные слои знать не должны.
      6. Подразумевается, что параметр не может быть null. Если это произошло в домене — однозначно что то пошло не так и бизнес-процесс далее не может выполнятся, что исключение в том числе и делает. Опять же, если это произошло в домене — это нештатная ситуация, требующая разбора полетов. Presentation layer (как и другие слои) должен проверить все данные перед тем, как передавать их в доменные сервисы. Не проверил и пропустил null — это баг. А сам по себе if (...) в 100 раз не замедлит выполнение

    • mayorovp
      /#19889196

      Представьте, что в вашем домене 40 свойств. Вы не можете напрямую их изменять из ваших сервисов, а значит у вас будет еше 40 методов на изменение этих свойст. Получается, чтобы просто поменять 5 свойств из модели, мы должны сделать 5 вызовов.

      А где тут написано, что нужен отдельный метод на изменение каждого свойства?


      Касаемо логики фильтрации. Представьте, что у вас два десятка полей, по которым можно фильтровать. Скорее всего, вы захотите использовать какой-нибудь экспрешион, а не писать 20! методов. Сразу возникает вопрос, где будет происходить его формирование в случае DDD.

      Так нужно же писать только те методы, которые будут позже использоваться, а не все возможные.


      Права доступа. Пример. Админ может менять паспортные данные, клиент — нет. Желательно, чтобы этих методов команда разработки клиентов просто не видела.

      Кажется, в DDD было что-то про фасады и контексты...


      Да EntityFramework и скрывает от вас реализацию Persistence. Вы можете использовать кучу конкретных провайдеров, хоть в память, а хоть и свой для сохранения в файл напишите.

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


      Чем более ограниченный интерфейс для слоя хранения используется — тем проще этот самый слой хранения писать.


      Никогда. Нет, не так. НИКОГДА так не делайте для бизнес логики. Просто померяйте скорость обработки исключений. Вместо 20к запросов в секунду у вас будет 200.

      Предполагается, что в рабочей программе эта проверка никогда не сработает. Исключение, которое никогда не возникает, ну никак не повлияет на производительность.