Comments 67
Если честно в статье постоянно об что-то спотыкаешься (как будто вы сами не до конца разобрались) например:
Из функционального программирования я бы порекомендовал взять уважение к неизменяемым структурам данных и избегание сайд-эффектов, где это возможно (например, CQS).
Если открыть вашу ссылку на вики, то там видно что cqs это больше про ООП
Ну и блок схемы ваши конечно прекрасны
@RakovskyAlexander у вас тоже спрошу - вот есть у вас заказ, там 100500 полей (от номера и даты до суммы, тегов, заметок и прочего). Когда создаете сущность - как вы передаете все эти 100500 полей из ДТО, которое пришло из контроллера (адаптера)? Еще одно ДТО на уровне Domain или в обшей DLL и маппинг между ними? Все 100500 полей пихаете в конструктор? Какой либо еще вариант?
Можно попробую ответить?) Вы когда результат с базы получаете можете его весь протолкнуть под типом "результат получения заказа" в конструктор программного объекта и обработать.
Да, мы уже общались в коментах по другой статьей )) - у вас table module.
И дружеское напоминание - речь идет не про конструирование сущности из БД, а про use case "создание заказа" или "обновление заказа". Когда DTO приходит извне.
А какая разница? У вас так же данные приходят в удобном для вас виде и вы их обрабатываете, создаёте тип "запрос от клиента" и от него отталкиваетесь. Ну и собственно ваше DTO приходящие из вне уже какой-то тип который можно положить в какой-то конструктор.
Группировать заказы можно же? Отдельным VO. Типа VO Address для 5 полей с адресом, VO tags для тегов и т.д. А в конструктор сущности передаются уже Address address, array tags и т.д. А их можно и отдельными DTO. Или я чего то не понял, влез не туда и это вопрос с подковыркой?
Скорее ответ:
Агрегатом из доменных сущностей.
Обычно доменная сущность и связанные обьекты.
Если обойтись без нарушения SOLID:
AutoMapper поддерживает вложенные обьекты,
FluentValidatior поддерживает вложенные обьекты,
EF поддерживает вложенные обьекты.
Возможно несколько ситуаций:
1) Агрегат должен иметь связанный обьект, но его нет - обрабатываем в useCase (специфичный агрегат)
2) Агрегат имеет связанный обьект - валидируем.
3) В валидаторе - если связанный обьект есть - валидируем.
Все, не надо городить 100 классов агрегатов (100 классов агрегатов - 100 проверок инвариантности). (да, я видел такое, и UseCase-ы в стиле UpdateFieldA(id,FieldA)) Обьясняют необходимостью инвариантности (BestPractice) - надо говорить что нужна только обеспечить валидность данных в хранилище.
Но это с позиции CRM, где гриды и карточки обьектов первичны.
Огромный плюс - 90% кода можно генерировать.
Богатая доменная модель:
Если с UpdateFieldA надо постоянно еще что-то делать - тут богатая модель, чтобы логику при UpdateFieldA не переписывать.
Вот, много текста и там ниже еще коммент ваш, давайте постепенно разберемся.
Значит, первое - я имел ввиду не про конструирование сущности из БД, а про use case "создание заказа" или "обновление заказа" или "отмена заказа".
В заказе есть куча полей, которые просто можно редактировать (заметки), и которые порождают сложные процессы и события (статус).
Далее, да вот я вычитал BestPractice - что сущность должна сама проверять свое состояние. То есть это свойства закрытые от изменений из вне (public get + private set).
Таким образом, чтобы в use case "создание заказа" мне создать заказ из DTO, где куча полей - все эти поля нужно добавить в конструктор. Аналогично, чтобы в use case "обновление заказа" изменить поля - надо нагенерить кучу методов UpdateXXX(). И да, некоторые из них (update заметки) будут просто проверять, например, длину значения (не более 255 символов), а некоторые (update статус) - будут запускать сложные процессы. Пожалуй даже вместо UpdateStatus лучше сделать методы Approve() или Cancel().
И вот мне эта тема с кучей параметров в конструкторе и кучей методов UpdateXXX не нравится.
Далее из вашего комента ниже
MediatR-style, для каждого useCase делаем команду и ответ.
Команды уже с доменной сущностью (это уже Application)
Это не решает проблему. Просто перекладываем из одно места в другое. Там все равно будет та же сущность с кучей параметров в конструкторе и кучей методов UpdateXXX. И все еще как-то надо подружить DTO и сущность.
AutoMapper поддерживает вложенные обьекты
Тут не совсем понятно - вложенные - это вложенные (они могут приватными и публичными) или приватные? Скорее всего приватные. Мне надо время, чтобы понять что вы написали. Отвечу чуть позже. Видимо у вас свойства сущности все же приватные, но вы в обход этого мапите их с использованием технического хака.
1) Инвариантность агрегата заключается в том, что вы не можете поместить в хранилише невалидный агрегат.
Если по неведомой причине смотреть на 2-3 строчки вверх в стектрейске ошибки лень -
Делайте кодогенерацию, инжектите свой сервис валидации, в set запускайте валидацию агрегата.
2) Богатая доменная модель нарушает SOLID, и доменный агрегат с 7 различными валидациями по статусу - мифический.
https://habr.com/ru/articles/931866/comments/#comment_28631840
Поэтому везде, где не Orleans, рекомендуется проверять состояние агрегата при помощи FluentValidator и проч. в источнике данных, или где вы его обновляете.
3) Сделайте единый веб-маппинг. 1 доменный обьект - 1 WebDTO
Откуда у вас появятся DTO со ста полями? Для такого должна быть доменная сущность со ста полями.
Если будете на каждый UseCase писать свое DTO для каждого агрегата -можете устать
Вот простейший пример
Данные для сущности
namespace Aggregate.Domain
{
internal class DomainData
{
public int Id { get; set; }
public int Count { get; set; }
public DomainObject Build()
{
var res = new DomainObject
{
Id = Id,
Count = Count
};
res.Seal();
return res;
}
}
}
Базовый класс для доменных сущностей
namespace Aggregate.Domain
{
abstract class DomainEntity
{
protected bool IsSealed = false;
public void Seal()
{
IsSealed = true;
}
}
}
И богатая доменная модель
namespace Aggregate.Domain
{
internal class DomainObject : DomainEntity
{
public int Id { get; set; }
protected int _count;
public int Count
{
get
{
return _count;
}
set
{
if (IsSealed)
{
//Проверяем
if (value < 0)
throw new Exception("Count Должен быть больше нуля");
}
else
_count = value;
}
}
}
}
Пример использования:
var data = new DomainData()
{
Id = 1,
Count = 100
};
var item = data.Build();
var data2 = new DomainData()
{
Id = 2,
Count = -1
};
var item2 = data2.Build();
Аггрегат 1 создается, Агрегат 2 нет.
В Build вызываем AutoMapper, и добавляем в конфигурацию
.AfterMap((c,s) => s.Seal());
Все, агрегаты прямо богатых доменных моделей мапятся автоматом.
Если пришел невалидный агрегат - получим ошибку при маппинге.
Если вам нужно получить еще неизменяемую сущность - DomainDbData
добавляете маппинги.
Если заморочится - можно просто добавить запуск FluentValidator-а в setter-ах.
Ну и весь этот бойлерплет генерировать )
то же самое, что и с Seal, можно сделать и с Lock (просто отключить сеттеры).
Если не лень делать лишние обьекты можно еще
добавить 3 обьекта DomainEntity - данные, DomainEntitySealed - валидный агрегат, DomainEntityLocked - Данные с БД, не изменяемые, только в валидный агрегат, и IDomainEntityLocked - интерфейс с {get;}.
Все, агрегаты какой угодно сложности с инвариантностью готовы (спасибо AutoMapper).
Данные в закрытые свойства через конструктор, возвращать из сервиса интерфейс. Все равно DomainEntityLocked будут только в БД, можно сделать internal.
Валидацию агрегата можно делать FluentValidator-ом
Меняем поле в set, вызываем Validate(this).
Все рекурсивно валидируется (если есть в агрегате).
Можно валидировать и одну доменную сущность, и какой угодно агрегат.
Ага, вот с кодом понятней. Так выходит что
В сущностях не все поля, а только "сложные" / с логикой и они имеют публичный set, но с IsSealed "оговоркой"
DomainData - это по сути DTO (придет из контроллера), содержит все поля, с публичными set, но с тем же успехом может быть классом для ORM, с маппингом на таблицу в БД
И логика следующая
Когда надо создать заказ - у нас есть DomainData, там все поля открыты, делаем валидацию и сохраняем в БД через ORM например
Когда позже начинаем работать с предметной областью - достаем из БД запись, конструируем DomainObject, там будет только часть полей, которые нужны для конкретных сложных бизнес случаев. По идее даже можно сделать их несколько штук, под каждый или группу UseCase, чтобы не делать один god класс, или сделать эту штуку интерфейсом.
Другими словами тут комбинация анемичной модели и богатой.
конструируем DomainObject, там будет только часть полей, которые нужны для конкретных сложных бизнес случаев
Это плохая практика.
Есть доменная сущность - надо оперировать доменной сущностью. И делать из них агрегаты.
Такие проекты (где тянут необходимые поля) очень быстро разрастаются и появляется дублирование логики. Архитектура прежде всего о расширяемости и поддерживаемости.
Можно делать 2 сущности - Full и Short одна с только необходимыми пролями, и полная (с длинными текстами и т.д.)
достаем из БД запись, конструируем DomainObject, там будет только часть полей, которые нужны для конкретных сложных бизнес случаев
Из репозитария уже приходит DomainEntitySealed. За пределами БД ничего нет, только команд на создание, и валидных агрегатов.
По идее даже можно сделать их несколько штук, под каждый или группу UseCase, чтобы не делать один god класс, или сделать эту штуку интерфейсом.
С валидацией мы уже разобрались - в сеттерах можно вызывать свой IValidator(с IValidationRule). в EF можно инжектить свои сервисы. Хотя у нас есть источники данных и можно инжектить все сервисы там.
У нас уже логика из класса ушла, правила перешли в IValidationRule, которые используются валидатором. Т.е. наша доменная модель не будет разрастаться.
А вся работа по созданию сущностей\агрегатов ведется через AutoMapper\FluentValidator, которые поддерживают деревья обьектов (поэтому мы можем смапить и провалидировать любую сущность\агрегат без лишних хлопот - достаточно 1 маппинга на сущность).
В общем, с базы получаем доменные сущности (богатые доменные модели).
Для агрегатов - если агрегаты используются много где - можно выделить отдельный сервис, возвращающий требуемый AggregateRoot.
Если доменная сущность не существует и не используется без связанных обьектов - добавляем атрибуты и инклудим нужное на уровне источника данных. (И в своем Include, которым строим агрегаты)
Ну и про специфичные поля
Специфичные поля - это на уровне команд (например, у нас есть действие ОбновитьПоле. Принимаем Id и значение поля. Например, это действие может выполнять лишь какая-то роль. В UseCase достаем по Id наш AggregateRoot, выставляем поле, сохраняем. Т.е. на уровне приложения новые доменные сущности создавать не надо, все что может пригодится можно построить через свой Include к AggregateRoot.
Ограничение на то, что обновляем задано в команде.
Ну и без кодогенерации и AutoMapper/FluentValidator все это превращается в кошмар - даже добавление полей становится задачей в 5 файлах, а правила валидации - неподдерживаемыми.
Богатая доменная модель из которой вынесены правила - удобно. Всегда можно отладить поток исполнения (сразу видно, где по статусу не так перевели\не то значение записали. Если логики - тысяча преобразований, ошибки видно сразу.
по специфичным агрегатам - могут быть случаи, когда модель не слишком валидна без связанных обьектов (валюты в счетах).
Тогда такие поля можно пометить атрибутом и уже по атрибуту вызывать свой Include в источник данных. (Кодогенерация)
В своем Include написать - если Include типа T(Или ICollection<T>), вызываем все дополнительные Include для сущности T. IIncudeConfiguration<T>. То же самое и в источнике данных.
Все, теперь любая доменная сущность и любые агрегаты будут приходить со всеми нужными обьектами.
Либо часто используемый агрегат выносить в отдельный сервис (например, Весь Заказ и прочее, чтобы не копипастить).
В итоге - со слоя с данными всегда приходят полные валидные обьекты\агрегаты. Вот такой Metadata-Driven framework для DDD :)
чтобы в use case "создание заказа" мне создать заказ из DTO, где куча полей - все эти поля нужно добавить в конструктор
Не нужно. Вам же уже сказали выше - в этом случае в конструктор надо передавать DTO со всеми этими полями.
Аналогично, чтобы в use case "обновление заказа" изменить поля - надо нагенерить кучу методов UpdateXXX().
Аналогично, не надо. Если хотите делать логику в сущности, то в сущности должен быть метод update, который принимает DTO с полями из формы обновления заказа в интерфейсе и содержит логику обновления.
Но я рекомендую вам подумать, как вы будете проверять в сущности длину поля, если бизнес попросит вас сделать максимальную длину например описания товара настройкой, которая хранится в базе данных.
Да, сейчас у меня этот вариант, но на интерфейсах
// Domain level
public interface ICreateOrder
{
string Number { get; set; }
DateTime Date { get; set; }
// еще 100 500 полей
}
public class Order
{
string Number;
DateTime Date;
// еще 100 500 полей
public Order(ICreateOrder dto)
{
// маппинг полей
}
public Update(IUpdateOrder dto)
{
// маппинг полей
}
}
// Application level
public class CreateOrderDTO : ICreateOrder
{
public string Number { get; set; }
public DateTime Date { get; set; }
// еще 100 500 полей
}
Интерфейс для DTO это довольно бессмысленная вещь. Если вам в параметрах нужен DTO, просто объявляйте DTO. Нет никакой проблемы в том, чтобы объявлять DTO в слое домена. Так же как enum-ы, value object-ы, и любые другие классы.
Главное - случайно не подумать, что это тот же самый DTO что используется в слое WEB API.
Я хочу попробовать интерфейс по двум причинам - чтобы не делать маппинги и, когда добавляется новое поле, компилятор скажет в каких DTO я забыл его добавить.
Так вам не нужны разные DTO, будет только один DTO, добавляете туда поле и всё.
Не очень понятно, что вы имеете в виду под маппингами. Вы же уже как-то создаете CreateOrderDTO и как-то его используете. Просто он будет в другом неймспейсе, и вместо ICreateOrder будет CreateOrderDTO.
В реальном проекте, на основе которого я пилю этот пет проект - есть два контроллера с немного разными DTO - когда заказ создается через API и когда через UI. И в реальном проекте с анемичной моделью и публичными сеттерами - используется автомаппер (из этих двух DTO прям в анемичную сущность).
Если бы я переходил на DDD и добавил бы еще один DTO на уровне Domain (вот который мы обсуждаем и который у меня как интерфейс) - у меня стало бы три штуки DTO. Если я добавляю интерфейс - я просто те два DTO для котроллеров наследую от интерфейса.
есть два контроллера с немного разными DTO
В это случае либо вы конвертируете один DTO в другой, либо у вас должно быть 2 разных метода с логикой, которые принимают на вход разные DTO.
Если я добавляю интерфейс - я просто те два DTO для котроллеров наследую от интерфейса.
Это то же самое, что конвертирование, только очень странное. С ненужной абстракцией, которая используется не по назначению. Интерфейс описывает поведение, у DTO нет поведения, значит интерфейс не нужен.
Если бы я переходил на DDD и добавил бы еще один DTO на уровне Domain - у меня стало бы три штуки DTO.
Нет, у вас был бы один или два DTO, с которыми работают контроллеры. В слое контроллеров в этом случае нет своих DTO.
И в реальном проекте с анемичной моделью и публичными сеттерами - используется автомаппер (из этих двух DTO прям в анемичную сущность).
С анемичной моделью сначала вызывается сервис, он делает нужные проверки, потом переносит данные в сущность и сохраняет ее. Переносить можно хоть вызовом сеттеров, хоть автомаппером.
Поскольку данные в сущность надо переносить в любом подходе, то автомаппер можно использовать и в вашем.
В слое контроллеров в этом случае нет своих DTO.
Это абсурд, при таком подходе детали внешнего интерфейса протекают в слой домена, и от DDD остаётся только название.
Правильный подход - именно три DTO. Или интерфейсы, они действительно выглядят странно - но хотя бы не нарушают структуру слоёв.
при таком подходе детали внешнего интерфейса протекают в слой домена
// namespace Domain
class CreateOrderDTO {
public string field1;
public string field2;
...
}
// namespace Application
import Domain.CreateOrderDTO;
class OrderController {
public function create(Request request): Order {
var createOrderDTO = new CreateOrderDTO();
createOrderDTO.field1 = request.get('field1');
createOrderDTO.field2 = request.get('field2');
...
}
}
Покажите пожалуйста, какие именно детали в CreateOrderDTO относятся к внешнему интерфейсу.
Если вы имеете в виду какие-то аннотации для фреймворка, чтобы он автоматически мапил request на dto, то можно сделать дублирующие DTO в слое контроллеров, но обычно никаких проблем с этим нет. Аннотации не влияют на остальной код класса и просто обеспечивают соединение слоев.
и от DDD остаётся только название
А когда вы в сущности пишете аннотации для маппинга на таблицу БД, или используете низкоуровневые типы, такие как четырехбайтовый int, не остается?)
У вас неправильный контроллер. Кто вообще сейчас вручную разбирает запросы и зачем?
public class OrderController {
public Order Create(CreateOrderDTO request) {
}
}
и всё, теперь CreateOrderDTO гвоздями прибит к схеме API.
Ну так я специально так написал, чтобы показать, что ваш вариант это просто оптимизация исходного, поэтому принципиально тут ничего не меняется. В PHP я такое делал просто через рефлексию, без специальных аннотаций в CreateOrderDTO. Вообще любые аннотации для описания можно считать комментариями в коде, в некоторых случаях для этого комментарии и используются. Импорт аннотации из какой-то библиотеки аналогичен использованию встроенных типов языка - DateTime, Integer, String, Array. Вместо Array в сущностях может использоваться Collection, который предоставляется ORM.
Не "CreateOrderDTO гвоздями прибит к схеме API", а "Схема API задается структурой CreateOrderDTO". Добавили поле в CreateOrderDTO, оно автоматически появилось в схеме. В том и смысл.
Нет, у вас был бы один или два DTO, с которыми работают контроллеры. В слое контроллеров в этом случае нет своих DTO.
Сейчас там в этих DTO для контроллеров есть дополнительные атрибуты (для документации API, для UI). И если я сложу их на уровень Domain - это все протечет в домен. И меня это смущает.
Альтернативы, которые я вижу
Третий DTO как класс + маппинги
Третий DTO как класс + наследование
DTO как абстрактный класс + наследование
дополнительные атрибуты (для документации API, для UI)
это все протечет в домен. И меня это смущает.
Если вы имеете в виду аннотации, а не поля класса, то обычно никаких проблем с этим нет. Это то же самое, что аннотации в сущности для маппинга на таблицу БД.
Если не хотите так делать, тогда можно сделать в слое контроллеров дублирующие DTO, и сделать в них методы конвертации toDomainDto()
. Тогда да, будет 3 DTO (или 4). Но 3-е DTO у вас и так есть, просто обозначается ключевым словом interface. Мне кажется, лучше явно указывать, что и куда вы передаете, чем прятать DTO из другого слоя за интерфейсом. Наследование тут точно не нужно, и вообще наследование DTO лучше нигде не делать.
Ну да, аннотации / атрибуты над полями в классе
public class CreateOrderDTO
{
[Required] [MaxLength(255)]
public string OrderNumber { get; set; }
}
А атрибуты для UI - это отдельная наша сборка, там у нас UI-движок, который генерит экраны на основе классов и атрибутов. В имени сборки есть слово "UI". И вот сидишь такой пилишь домен, и там в зависимостях UI показывает, что увеличивает кол-во WTF-ов при чтении кода на +1 😅
как вы будете проверять в сущности длину поля, если бизнес попросит вас сделать максимальную длину например описания товара настройкой, которая хранится в базе данных
Чертовски хороший вопрос!
После попадания ответа в конструктор вы можете делать что угодно - создавать объекты (фабрика) или создать 1 объект на весь пул данных. Автор изначального комментария актив рекордом смотрит, наверное
Вы устанете создавать 100 конструкторов, в 100 агрегатах )
Или делать сто .toDomain() .fromDomain() как Автор.
И делать там проверки и фабрики.
Если речь про веб - у вас могут быть очень сложные агрегаты, деревья из 5 типов обьектов и проч. (И деревья из трех типов обьектов)
Уже 2 разных агрегата с разной валидацией, или 2 разных конструктора и 2 логики валидации(!). Сложной валидации. (Потому что у одного агрегата проверяем привязанные обьекты на 3 уровне дерева, а другого - нет).
Но это с позиции CRM, где гриды и карточки обьектов первичны.
Возможно, где-то есть use-case-ы где надо прямо валидировать в момент изменения.
Например, Orleans, где нет хранилища, и нельзя все проверять там.
Но из-за непонимания DDD/CleanArchitecture вы скорее всего услышите про Инвариантность Обязательно в get/set, так написано! Rich Domain Model!
Ну и из FP - да чистые обьекты и функции в Domain/Application! Без инфраструктуры!
Ну нет же, у меня приходит CreateOrderDTO из контроллера, в БД еще ничего нет. Мне надо валидировать DTO, создать сущность, запустить некоторые сложные процессы (путем отправки события "создан заказ") и сохранить это в БД. И в данный момент проблема с "создать сущность" из этого DTO, так как у сущности все свойства имеют private set.
Эмм, технически можно, но по логике - нет потребности. Просто есть use case "создание заказа" или "обновление заказа" или "отмена заказа".
Нет, подковырки нет, у меня реально стоит вопрос, я тут решил изучить наконец-то DDD на примере моего реального сложного проекта, который сделан по анемичной модели. И там возникает куча вопросов что и как делать.
Привет!
В нашей практике, маппинг дто на доменные объекты происходит в простом случае в коде дто. Мы просто делаем метод что-то вроде dto.toDomain(), внутри которого вызываются конструкторы или фабрики домена, и обратный статический Dto.from(). Ну только имена поприятнее.
И я бы на этом остановился, но поделюсь такой проблемой: тут есть нюанс с валидацией и тестированием. В нашем процессе мы делим автотесты на быстрые без запуска приложения и медленные на запущенном приложении. Сами быстрые, в свою очередь, делятся на тесты адаптера, где мы его тестируем с конкретной технологией, и тесты приложения, где мы проверяем именно логику приложения и домена через интерфейсы приложения.
Так вот, в описанном подходе, если есть какая-то валидация доменного объекта при создании (например, в фабрике), то, хоть логика этой валидации и лежит в домене, но тесты, как будто, приходится писать либо в адаптере, либо отдельно на фабрику. Либо, третий вариант: использовать, как вы сказали, промежуточный объект, который, в отличие от модели, может быть в невалидном состоянии - этакий builder из gof. Последнее решение рекомендует дядя Боб в своей Чистой Архитектуре.
Но, например, Алексей в своём Эргономичном коде не использует такого деления тестов и, как следствие, таких проблем не имеет. Но он говорит, что и гексагоналку почти не использует, что логично, ведь главный плюс гексагоналки проявляется только при таком делении тестов. Но зато у нас есть тест-сьют, который едет со скоростью 500 тестов за пару секунд.
Сразу не увидел этот коммент.
В нашей практике, маппинг дто на доменные объекты происходит в простом случае в коде дто. Мы просто делаем метод что-то вроде dto.toDomain(), внутри которого вызываются конструкторы или фабрики домена, и обратный статический Dto.from()
Это все еще не раскрывает решения проблемы маппинга. Свойства у сущности имеют private set? Как тогда маппинг, если они приватные?
Либо конструктор, либо фабрика, либо фабричный метод. В зависимости от сложности модельного объекта. В простом случае конструктор. Если нужна валидация, то фабричный метод. Если есть полиморфизм, то абстрактная фабрика.
Сеттеры на доменных моделях, как правило, отсутствуют вовсе. Геттеры только по необходимости.
Либо конструктор, либо фабрика, либо фабричный метод
К сожалению я все еще не вижу решения - как это на самом деле работает.
Может давайте псевдо кодом?
public class Order
{
string Number;
DateTime Date;
// еще 100 500 полей
public Order(/* 100 500 полей*/)
{
}
// 100 500 методов
public void UpdateXXX()
{
}
}
public class CreateOrderDTO
{
public string Number;
public DateTime Date;
// еще 100 500 полей
}
Допустим будет фабрика
public class OrderFactory
{
public Order Create(CreateOrderDTO dto)
{
// Валидация DTO (поля публичные - проблем нет)
// Вот тут у вас что?
// Так?
return new Order(dto.Number, dto.Date, /* остальные 100 500 полей из DTO*/)
}
}
Очень близко! На самом деле, основных вариантов два:
Самый простой, нет никакой валидации:
public class CreateOrderDTO { public String number; public DateTime date; // еще 100 500 полей Order toOrder(){ return new Order(number, date, ...); //100 полей } // или... Order toOrder(){ return Order.builder() .number(number) .date(date) // 100 полей .build(); } // То же самое, но чуть понагляднее. // В целом, я билдеры не рекомендую, // потому что они порой неприятно // привязываются к структуре классов // и потом больно рефакторить. }
Более сложный, необходима валидация. Тогда мы передаем логику валидации в фабричный метод класса, чтобы логика не утекала из домена и делаем конструктор приватным, чтобы нельзя было создать объект в невалидном состоянии.
public class CreateOrderDTO { public string number; public DateTime date; // еще 100 500 полей Order toOrder(){ return Order.from(number, date, ...); //100 полей } }
public class Order { string number; DateTime date; // еще 100 500 полей // тут private-метод, потому что нельзя допускать создание невалидного объекта private Order(/* 100 500 полей*/) { } public static Order from(String number, .../* 100 500 полей*/) { if (! number.matches("[0-9]+")) { throw new NotValidException("Номер не номер!"); // Ну или можно сделать иначе: // https://martinfowler.com/eaaDev/Notification.html // Но, вообще говоря, этой проверочке место // в отдельном классе Number, который должен // использоваться вместо строки. } return new Order(/* 100 500 полей*/) } }
Ну и есть еще варианты с полиморфным созданием, с фабриками, абстрактными фабриками и прочей шляпой под определенные задачи и проблемы. Я начал их рисовать и понял, что это уже вопросы отдельных паттернов.
Мда, даже в коментах под этой статьей я собрал три разных варианта как делать DDD. 😅 И это лишь малая толика всего, с чем реально придется столкнутся.
А вы как-то облегчаете кодинг всех этих параметров для конструкторов и методов для обновления? Кодогенерация?
Ну, я в статье несколько раз упоминал, что нет одного правильного способа. Просто потому что нет исследований, которые бы подтвердили, что одно правильнее другого. Поэтому смело можно брать тот вариант, который кажется сейчас правильнее. Потом все равно поток новых знаний заставит скорректировать взгляды.
По поводу вопроса:
Я не припомню, когда в последний раз нам требовалось мучиться с огромным количеством полей, которые бы могли меняться все без исключения. Что-то похожее решаем ломбоком с публичными сеттерами, если никакой валидации при изменении не требуется. Если требуется - это отдельный разговор.
Передаю привет тем кто заставляет пользователя ввести 100500 полей. Ну а если серьезно - раз все эти поля прошли какие то валидации и их так много, то пользователь вероятно расстроится если вы их разом потеряете и ему придется вводить их заново. Да, я намекаю что вам в домене нужна сущность "Черновик заказа", в нее будет происходить маппинг из входной Dto в вашем anti corruption layer (Controller например), а далее она попадает в уровень приложения где ее встречает OrderBuilder и уже докручивает до полноценного заказа. Собирать такой крупный объект в конструкторе я не советую.
Критиковать все могут, код покажите 😅
сущность "Черновик заказа"
Так то не поможет, напоминаю, что любая сущность по умолчанию имеет приватные сеттеры, чтобы сохранить свои инварианты. И то, что сделали новую сущность - совершенно никак не решает проблему. В новой сущности все так же надо делать 100500 полей в конструкторе или ... А вот на какой компромисс идете конкретно вы?
Когда вся логика лежит в сервисах, а не в доменных объектах, то это все еще работает, но уже не особо что-то моделирует.
Моделирует. То, что и должна - бизнес-требования.
// Если заказ успешно оплачен, перевести заказ в статус "Оплачено"
// и отправить письмо пользователю с информацией о заказе.
class OrderService {
public function pay() {
...
$paymentResult = $this->sendPayment($order, $paymentDetails);
if ($paymentResult === PaymentResult::Success) {
$this->moveOrderToStatus(OrderStatus::PAID);
$this->sendSuccessEmail(order);
}
}
private function moveOrderToStatus(OrderStatus $status) {
$order->setStatus($status);
$this->entityManager->save($order);
}
}
Как вы будете делать оплату и отправку email из сущности, непонятно. Учтите, что отправку email надо делать после сохранения сущности.
Обратите внимание, что в бизнес-требованиях написано "перевести заказ в статус N", а не "перевести это в статус N". Поэтому код $order->setStatus(N)
более правильно моделирует бизнес-требования, чем $this->status = N
.
Таким образом вы лишили себя всех преимущств ООП: высокого cohesion, инкапсуляции модели, полиморфизма для разруливания дублирования и абстракции логики. Помимо этого вы перемешали логику уровня приложения с логикой предметной области, и теперь каждый раз надо отделять виски от колы, чтобы что-то понять.
Богатая модель - антипаттерн SOLID.
Попробуйте как-нибудь сделать обьект с 7 различными проверками в зависимости от статуса, например.
А теперь добавим функции сохранения, расчета...
В том же EF инжектить сервисы в обьекты - проблематично.
Когда создаете сущность - как вы передаете все эти 100500 полей из ДТО, которое пришло из контроллера (адаптера)?
MediatR-style, для каждого useCase делаем команду и ответ.
Команды уже с доменной сущностью (это уже Application).
Запросы и ответы - и есть отвязка Application от Infrastructure.
Можно сразу переиспользовать все UseCase-ы, и вызывать их откуда угодно.
Маппинг WebDTO-DomainEntity в Infrastructure.
Куда сложнее история с тем, что функциональный код часто весьма непросто читать непривычным взглядом.
Вопрос привычки. Если взять того кто с функциональщины начинал, для него ООП также будет взрывом мозга.
Сложность реализации тривиальных вещей может быть избыточна.
Ну во-первых, ФП ФП рознь. Есть экстремальные вещи вроде чистого Haskell подобного ФП а есть умеренные вроде Clojure.
Во-вторых, в ООП такая же история. Сколько часто нужно нагородить классов, отнаследоваться, попереопределять, открыть закрыть чтобы написать тривиальную вещь на ООП языке.
Во всех остальных случаях более сложная доменная модель имеет сильное преимущество в поддерживаемости: моделирование делает код нагляднее, в абстрагирование снижает coupling и позволяет убрать дублирование.
Это правда. Но почему то все забывают что это привносит кучу проблем, потому что такое моделирование не налазит на реальную действительность: работа с базой превращается в костыль, повсюду маппинги ради маппингов, проблема с инвариантами объектов т.к. домена 18 полей а из интеграции пришло 3, что же делать, и прочее прочее прочее.
У ООП свои очень серьезные преимущества, которые я рекомендую не игнорировать. Повторюсь, эти преимущества:
высокий cohesion за счет хранения данных и поведения в одном месте
хорошие инструменты инкапсуляции модели, защищающие вашу модель от некорректного состояния
полиморфизм, позволяющие устранять дублирование там, где в ином случае вы были бы обречены.
Может быть в теории. На практике это не очень работает и почти все пишут опираясь на анемичную модель.
По поводу полиморфизма - забавно. Во первых, не в ООП придумали полиморфизм. Во вторых в ООП используется не самый его продвинутый его вид. Так что в чем эти серьезные преимущества непонятно.
Анемичная доменная модель
Когда вся логика лежит в сервисах, а не в доменных объектах, то это все еще работает, но уже не особо что-то моделирует. Таким образом вы лишили себя всех преимущств ООП: высокого cohesion, инкапсуляции модели, полиморфизма для разруливания дублирования и абстракции логики. Помимо этого вы перемешали логику уровня приложения с логикой предметной области, и теперь каждый раз надо отделять виски от колы, чтобы что-то понять.
В целом, анемичная доменная модель - это Transaction Script с издержками доменной модели, худшее из двух подходов.
Опять таки, в теории да. На практике, лично на моем опыте, богатает модель просто нежизнеспособна. Оно все хорошо только и только пока нам хватает данных которые есть в объекте для валидаций и вычислений. Как только нам нужно что-то еще, все это превращается в тыкву и костыль. И судя по тому что подавляющее большинство используют анемичную модель, это не только мои выводы.
Поэтому вместо необоснованного повсеместного отказа от ORM я бы предложил скорее решать проблемы точечно: настраивать ORM под свою задачу и использовать обычные запросы там, где эффективности ORM вам не достаточно.
Да ладно! Вы решили, что ORM - это не религия, а всего лишь инструмент управления сложностью, он не всегда подходит и надо им пользоваться там, где его применение оправдано?
Удивительное дело, всегда когда агитируют за использование тактики DDD почему-то в статьях и примерах кода забывают упомянуть вопросы работы с транзакциями, выбора границ агрегатов и подходы к обеспечиванию консистентности между разными сервисами и даже разными агрегатами в рамках одного сервиса. А это очень важный (на мой взгляд даже ключевой) момент в тактической части DDD: Пиррова победа Domain-Driven Design.
Мне не известны надежные свидетельства того, что описанные в первой части идеи достоверно делают разработку проще, дешевле или приятнее.
С этим помогает отказ от религиозного следования догмам. Тактическое DDD - это очень дорогое удовольствие, и нет никакого смысла платить эту цену в 95% сервисов где просто нет настолько сложной бизнес-логики. Никакое "единообразие архитектуры проекта" не стоит этой цены! Поэтому стоит использовать в абсолютном большинстве сервисов Transaction Script (разумеется, плюс гексагональную), и прибегать к тактическому DDD только в тех сервисах, где сложность бизнес-логики действительно на порядок выше средней.
Не заметил определения "чистой архитектуры". Желательно измеримой.
Рисунок, приведенный в разделе "Структура" данной статьи, по сути является квинтэссенцией написанного по архитектуре за последние 20 лет. Но всегда можно найти неприятные вопросы, которые портят такую стройную схему.
Рассмотрим приложение с визуальным интерфейсом. То есть входным адаптером будет визуальная форма. Конечным хранилищем данных является база данных.
На визуальной форме вводятся данные для новой сущности "Документ", которая сохраняется в таблицу Documents базы данных. Бизнес-логика приложения предполагает, что название каждого документа (поле Title в таблице Documents) является уникальным в рамках этой таблицы. То есть перед сохранением нового документа в бд его надо валидировать путём запроса к бд на предмет уникальности названия документа в рамках поля Title таблицы Documents.
Вопрос - этот запрос к бд Вы протяните с визуальной формы через всю цепочку слоёв или напрямую с визуальной формы обратитесь к DocumentRepository?
Добрый день)
Это, на самом деле, 2 вопроса:
Потащим ли мы вызов из одного адаптера в другой через сервисный слой?
Ответ: да, обычно мы так делаем, но это не обязательно. Делаем, потому что недорого, но при этом добавляет абстракции.
Как правильно валидировать уникальность наименования документа?
Ответ: уникальность наименования - это бизнес-правило, оно должно быть в домене. Если документов немного, то идеальный способ реализации: загрузить все документы из базы в класс Documents, вызвать на нём метод add, который провалидирует, возможно ли это добавление, по всем необходимым бизнес-правилам, в том числе и проверит уникальность. Делегировать проверку констрейнту в базе - значит, нарушить инкапсуляцию нашей модели: теперь часть бизнес логики в домене в модели, а часть - в хранилище в базе данных.
Универсально ли предложенное мной решение? Нет, если документов слишком много, оно будет работать долго и требовать много памяти. Поэтому есть DDD-трилемма. Из трёх свойств: инкапсуляции, изоляции и производительности вы можете выбрать только 2. Подробнее рекомендую посмотреть тут: https://youtu.be/JOy_SNK3qj4?si=WHt8iUkhT1y5zD8q
Потащим ли мы вызов из одного адаптера в другой через сервисный слой?
Из Вашего рисунка это как раз отлично видно, что запрос из входного адаптера всегда проходит через сервисный слой в исходящий адаптер. Мой вопрос как раз в другом - допускает ли такая архитектура при необходимости прямое обращение из входного адаптера (визуальная форме) к исходящему адаптеру (DocumentRepository)?
оно будет работать долго и требовать много памяти
А ещё данные в БД могут за это время измениться. Потому что сервер параллельно обрабатывает много запросов и таких серверов одновременно работает больше одного. А блокировать нельзя, потому что встанет колом вообще вся система.
Сама концепция наличия в памяти полной модели данных (даже если часть этих данных на самом деле не в памяти а будет магически подгружена ORM при обращении) для обеспечения консистентности этих данных была актуальна в то время и для тех технологий, когда появился DDD - а это более 20 лет назад! Этот поезд давно ушёл. Для современных проектов это совершенно нетипичная и малореалистичная картина.
Микросервис, отвечающий за уникальность заголовков - это же считается норм решение, не нарушающее всякие изоляции, верно? Ну вот и воспринимайте PostgreSQL не тупым хранилищем а таким микросервисом, который следит за уникальностью заголовков, вместо того, чтобы страдать из-за "ой, у нас бизнес-правило из кода домена протекло в схему БД". Просто проверка в бизнес-логике данного правила выполняется не поиском по списку всех заголовков в памяти а попыткой сохранить сущность в БД.
Но… это будет уже не DDD, верно? Потому что DDD ставит задачу написать бизнес-логику так, чтобы её мог прочитать и проверить не программист, а бизнес-заказчик. И тут кусок бизнес-логики оказавшийся очень далеко от изучаемого бизнес-заказчиком кода - аж в схеме БД, - никак не вписывается. Но как часто бизнес-заказчики (не являющиеся программистами) реально читают код бизнес-логики написанный идеально по DDD? Я вот о таком в реальных проектах не слышал в принципе, а Вы? А программист, честно, уж как-нибудь переживёт тот факт, что некоторые элементы бизнес-логики ему нужно смотреть не в этом файле с кодом а в соседнем файле со схемой БД. Программист нуждается в DDD чтобы реально сложную бизнес-логику было возможно загрузить в голову, понять, и корректно изменить - и для этого 100% идеальное следование всем требованиям тактики DDD не требуется, особенно когда из-за попытки делать идеальный DDD общая сложность только вырастает, производительность падает, и появляются новые вызовы - вроде необходимости корректно реализовывать и поддерживать логику компенсации саг.
Абстракция для коллекции документов это репозиторий DocumentRepository, добавлять еще одну это неправильно.
Даже если вы добавите, как сохранить в базу новый документ после вызова Documents.add? Удалять все и сохранять заново все которые есть в Documents? Это не DDD и не трилемма, это просто неправильная архитектура.
Добавлять бизнес-логику в DocumentRepository тоже неправильно.
Documents может использовать DocumentRepository, но тогда и загружать все документы не надо. То есть Documents превратится просто в сервис с логикой, который использует репозиторий.
Да, сохранять весь набор документов списком. Паттерн называется Aggregate из оригинальной книжки DDD.
Мне кажется, вы не совсем понимаете, что такое Aggregate в DDD. Коллекция это не Aggregate.
https://martinfowler.com/bliki/DDD_Aggregate.html
Aggregate is a pattern in Domain-Driven Design. A DDD aggregate is a cluster of domain objects that can be treated as a single unit. An example may be an order and its line-items.
DDD Aggregates are sometimes confused with collection classes (lists, maps, etc).
Есть мнение, что в момент, когда на коллекцию навешивается бизнес-требование (уникальные заголовки, например), она перестаёт быть "generic" и вполне может быть представлена как агрегат. Потому что если нет, то где тогда описывать данное бизнес-правило?
Если кому будет интересно - резюме вариантов, как пропихнуть DTO в сущность
Через 100 500 параметров в конструкторе и 100 500 методов UpdateXXX https://habr.com/ru/articles/931866/comments/#comment_28633638 и https://habr.com/ru/articles/931866/comments/#comment_28640456
Сущность имеет публичные сеттеры, но с признаком IsSealed, который запрещает устанавливать значение из вне (такие сущности конструируются, когда достаются из БД) https://habr.com/ru/articles/931866/comments/#comment_28634360
Сущности имеют приватные сеттеры + DTO на уровне Domain (лежат рядом с сущностями). Тут мой вариант с интерфейсами, но можно и с классами https://habr.com/ru/articles/931866/comments/#comment_28636082 (дискуссия еще продолжается)
Про архитектуру приложений для тех, кому мало Чистой архитектуры