Search
Write a publication
Pull to refresh

Comments 21

В классическом DDD традиционно выделяют несколько слоёв.

Самоя популярная ошибка в начале статьи. DDD (ни тактический, ни стратегический) не слышал никогда о каких то слоях..

Вы смешиваете DDD и луковичную(слоистую) архитектуру - не надо так.

Спасибо за комментарий!
DDD сам по себе не навязывает луковичную или слоистую архитектуру — это просто один из удобных способов изолировать доменную логику от инфраструктуры. В книге Эванса упомянуто разделение на «application» и «domain», но нигде не сказано «слой обязателен». В статье я показал именно такой подход, потому что в реальных NestJS-проектах он часто упрощает поддержку. Но да, формально DDD не требует какой-то одной архитектурной схемы.

Все красиво, пока не откроешь маппер, тк все эти слои нужно как-то связывать.

В минимальном примере из статьи уже 5 конвертаций только для ордера. Почти столько же для Order Item, а вложенные энтити в агрегате это вообще жесть. И это у нас ещё нет подтипов с вычисляемыми полями.

  1. Controller -> Application

  2. Application -> Domain

  3. Domain -> Persistence

  4. Persistence -> Domain

  5. Domain -> Controller

Упрощённая инициализация в конструкторе доступна только пока параметры передаются массивом. Когда их с десяток, начинаешь теряться в последовательности. Очень легко нечаянно вторым параметром передать цену, а третьим количество. Поэтому или нужно переводить в объект и прописывать ещё один маппинг в теле конструктора, или добавлять фабрики.

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

Маппинг может казаться обременительным, но он помогает чётко отделить доменную модель от инфраструктуры и даёт гибкость при изменениях. Если домен сложный и будет расти, эта боль окупается. ORM (например, EF Core) тоже не всё делает по магии: да, для базового случая работает автоконфигурация, но при нетривиальных требованиях (сложные связи, собственные схемы, специальные правила маппинга) придётся немало прописывать вручную.

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

Про реализацию DDD на Nestjs информации немного, а книг нет вовсе. Продолжайте. Некоторые моменты.

Интерфейс это часть Приложения - gui, cli, api. Приложение без интерфейса не имеет смысла.

DDD ориентирована на поведение, а не на данные. Сущности и Репозитории часто воспринимаются как хранилище вроде ORM. Сущность имеет место быть в домене, но попробуйте без нее, сосредоточив логику в сервисе. Возможно тогда уйдет pay из Order.

Используйте Value objects. Попробуйте разрешить атомарные типы только в них, удивитесь как преобразится код.

Сущность имеет место быть в домене, но попробуйте без нее, сосредоточив логику в сервисе.

Поясните, пожалуйста, Вы про анимичную модель или про что-то другое? Если второе, то расскажите подробнее, пожалуйста.

Да, вроде нее. Новичок может подумать что проект всегда надо структурировать как в статье. Идеально бы посмотреть проект целиком и оценивать его инварианты структуры в масштабе.

Мне, анимичная модель не близка, но и необходимость сервисов очевидна. Я за подход описанный классиками: если логика затрагивает одну сущность, скорее всего, ей место в методе класса этой сущности, а доменные сервисы нужны, если изменяются несколько сущностей-агрегатов или просто автономных сущностей (это важно, потому, что изменением подчиненных сущностей, вероятно, должен заниматься метод агрегата). Мне кажется, инкапсулируя поведение и соблюдение инвариантов сущности в нее саму, мы существенно упрощаем логику внешних сервисов: сервис знает интерфейс сущности, и программисту сервиса нет необходимости думать о реализации этой логики и, возможно, даже знать о ней. Это освобождает оперативную память в голове )

Спасибо за комментарий!
Интерфейс не всегда является обязательным элементом. Существуют сценарии, где главная ценность - это API или служебная библиотека. Сущности и репозитории часто действительно сводят к хранению ради удобства ORM, что может отдалять от исходных целей DDD. Перенос логики в сервисы может облегчить сущности, но усложнить связь между ними и состоянием, если бизнес-правила тесно привязаны к жизненному циклу объекта. Value Objects сокращают использование примитивов и делают код выразительнее, однако важно соблюдать баланс, чтобы не превратить проект в набор мелких обёрток.

Написал выше в комментарии что важно видеть проект целиком. У DDD есть версия Lite, которая принимается, когда команда не вывозит больше когнитивную сложность или проблемную область. Возврат техдолга, другими словами. Второе практическое применение DDD - блеснуть на техническом интервью. Заметил, что многие техлиды любят DDD, не используя его у себя.

Я бы из quantity тоже сделал value object для демонстрации factory fromString, встроенной валидации, которую куда только не ставят, и bounded context, когда в домене корзины разрешены положительные инты, а в домене склада возможны и нули.

В целом это все применимо и к java/kotlin. Но разделять лук и домены нужно.

Я, честно, еще далек от как полного понимания ДДД так и от его реализации при помощи многослойных архитектур (события, транзакции через единицы работы, определение границ агрегатов и другие моменты - все это вызывает больше вопросов, чем является для меня рабочим инструментом, тем более что, для условий PHP эти паттерны нужно переосмысливать), но хотелось бы уточнить такие моменты:

1) "В DDD часто используют интерфейс репозитория в доменном слое, а реальную реализацию помещают в инфраструктурный слой."

Мне кажется, что взаимодействие с репозиториями скорее должно быть на уровне приложения (use-cases), а доменные сервисы должны работать с уже готовыми сущностями и не заморачиваться вопросами получения/сохранения. Возможно, у Вас есть примеры, когда предпочитаемый мной подход не срабатывает? Но в других местах статьи Вы помещаете обращения к репозиториям в слой приложения. Просто опечатка?

2) "Например, Entity отвечает за данные, Domain Service - за бизнес-логику, а Infrastructure Service - за взаимодействие с внешними системами."

Вы исключаете логику из Entity, т.е. выступаете за анемичную модель? Но в другом месте Вы же пишете:

"Симптом: файл domain.ts, в котором вроде бы описаны Entities, а по факту там класс ради класса. Или Value Object, который не содержит никакой логики, а просто один кортеж полей."

Т.е. критикуете анимичную модель. Поясните! )

3) Вы не рекомендуете создавать ValueObjects, если они не содержат логику, но, у меня есть соображение, для чего полезны ValueObject без логики: они обеспечивают строгость типизации по бизнесовым принципам. Например, если сервис вычисления среднего значения получает два числа, мы можем случайно отправить в него одновременно вес и длину - они оба числовые, но вот что за значение мы получим на выходе? С другой стороны, при создании классов Weight и Length, перепутать будет сложно, но понадобится два сервиса/метода, причем, не только для вычисления среднего, но и для сложения/вычитания, если это нужно для бизнеса )

4) Поскольку метод pay() в сущности Order реализует просто изменение статуса заказа на "Оплачено", возможно, название стоило уточнить на что-то вроде markAsPaid, чтобы не смешивать с сервисом, который будет реализовывать логику связанную со списанием/зачислением денег и не путать других читателей-комментаторов ).

Спасибо за фидбек!

1) Я определяю интерфейс репозитория в доменном слое, чтобы выразить, какие операции нужны для работы с доменными объектами, но при этом реальную работу (вызовы save(), findById() и тд) провожу из приложения (use cases). Так домен остаётся чистым и не зависит от инфраструктуры, а слой приложения решает, когда именно обращаться к репозиторию. В статье это не опечатка, а просто демонстрация того, что интерфейс живет рядом с доменными сущностями, а использование - на уровне приложения.

2) Я не призываю полностью "очищать" сущности от логики. Если метод напрямую меняет состояние или проверяет инварианты (например, order.markAsPaid() с проверкой статуса), то ему место в сущности. Если нужно задействовать инфраструктуру или другие сущности, лучше вынести это в сервис. В статье я критиковал ситуации, где сущность - это просто набор геттеров/сеттеров без логики.

3) Бывает полезно заворачивать простые типы в ValueObject, чтобы защититься от ошибок и явно отразить бизнес-понятийный уровень. Считаю это своего рода "логикой", поскольку мы запрещаем смешивать несовместимые типы. Однако, если никакого смысла и валидации в VO нет, можно оставить примитив.

4) Вы правы, лучше назвать markAsPaid(), чтобы сразу отражать суть. Если там нет процесса списания денег, то название pay() может путать. Если же есть реальная финансовая логика, тогда это уже задача сервиса, а сущность лишь меняет свой статус.

Вот про "меняет свой статус" интересно. Это уже другой объект в памяти, который не может быть равным предыдущему по equals, верно? Это нормально в паттерне Active Record в ORM внутри инфраструктуры, но не в домене, по моему мнению.

Я определяю интерфейс репозитория в доменном слое, чтобы выразить, какие операции нужны для работы с доменными объектами, но при этом реальную работу (вызовы save(), findById() и тд) провожу из приложения (use cases). 

Получается, в домене лежит интерфейс, который доменом не используется, почему бы не положить его на уровне приложения? )

Спасибо, очень полезная статья с простыми и понятными примерами, которые можно использовать как вводный материал для разработчиков (причем не только JS), которые ранее не сталкивались с DDD. Добавил в закладки.

Единственное что я бы изменил это группировку классов в слое. С моей точки зрения удобнее группировать по доменным агрегатам а не по типу классов. Т.е.

domain
  order
    OrderReposityInterface.ts
    Order.ts
    OrderService.ts
    OrderItem.ts


Ну и интерфейс репозитория с моей точки зрения должен объявляться в слое приложения. На моей практике не было не одного случая, когда репозиторий был нужен в слое домена.

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

Чем value object отличается от дто ?

import { IsNumber, IsString, Min, Length } from 'class-validator';

export class MoneyDto {
  @IsNumber({}, { message: 'Amount must be a number.' })
  @Min(0, { message: 'Amount must be a positive number.' })
  amount: number;

  @IsString({ message: 'Currency must be a string.' })
  @Length(3, 3, { message: 'Currency must be a 3-letter code (e.g., USD).' })
  currency: string;
}

Спасибо за статью. Касательно реализации репозитория есть нюанс:

...
  save(order: Order) {
    this.storage[order['id']] = JSON.stringify(order);
  }

  findById(id: string): Order | null {
    const data = this.storage[id];
    return data ? JSON.parse(data) : null;
  }
...

JSON не может иметь методов, потому JSON.stringify() не создаст точную копию объекта в строке. И потому обратная конвертация JSON.parse() по факту не является Order.

Вместо этого стоит на месте создавать Order из JSON'а:

  findById(id: string): Order | null {
    const data = this.storage[id];
    return data ? new Order(JSON.parse(data)) : null;
  }

Случай усложняется, если Order содержит структуры данных типа Map/Set, которых также нет в JSON. Тогда нужно будет заводить какой-то отдельный маппер

Sign up to leave a comment.

Articles