Главная / Статьи / Шесть правил DDD для структурирования доменов в приложениях NestJS

Шесть правил DDD для структурирования доменов в приложениях NestJS

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

1991 слов

Через полгода работы с большинством проектов на NestJS появляется знакомая проблема. Вы добавляете одно поле для реализации определенной функции, и тест в совершенно независимой части приложения вдруг начинает выдавать ошибки. Кто-то из команды спрашивает, где на самом деле находится логика обработки заказов, и честный ответ звучит как «распределена немного повсюду».

Это не признак небрежной разработки. Обычно это означает, что код был структурирован вокруг технических слоев, а не вокруг концепций, которые он представляет. Полноценный подход Domain-Driven Design — это сложная практика, которую большинство команд так и не применяют в полной мере. Ниже представлен более лаконичный вариант: шесть правил, которые действительно приносят пользу в приложениях на Nest, без лишних формальностей. Можно считать это селективным DDD.

Правило 1 — Не заставляйте одну модель обслуживать всё приложение

Почти в каждой запутанной системе с задним фронтом в её центре находится так называемый «объект-бог». Его часто называют Order; у него десятки столбцов с возможностью значений null, и половина кодовой базы импортирует его. В конечном итоге изменение, внесённое для команды склада, тихо нарушает работу процесса оформления заказа.

Этот единственный объект Order на самом деле представляет собой три разных аспекта, скрытых под одним названием:

  • Оформление заказа касается цен, скидок и информации о намерении осуществить оплату.
  • Обработка заказа касается идентификаторов товаров и адресов доставки, не затрагивая скидки.
  • Выставление счёта касается суммы и номера счёта-фактуры.

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

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

class Cart {
  lines: CartLine[];
  discount: Money;
  paymentIntentId: string;
}
class FulfillmentOrder {
  orderId: string;
  shipTo: Address;
  picks: Pick[];
}
this.events.emit(new OrderPlaced(order.id, order.shipTo, picks));

Обратите внимание, что FulfillmentOrder идентифицируется только по orderId и совсем не содержит данных о ценах или скидках — процесс оплаты передаёт информацию о заказе через событие OrderPlaced, вместо того чтобы использовать собственный внутренний класс. Это означает, что изменения цен никогда не смогут повлиять на логику склада, даже если компилятор это позволяет. Каждая из этих областей представляет собой ограниченный контекст: у неё есть своя собственная модель, в которой термин «заказ» означает одно-единственное конкретное понятие. В монолите это может быть просто набор модулей с собственными таблицами базы данных; в архитектуре микросервисов — совершенно отдельный сервис. В любом случае правило остаётся прежним — никогда не делить модель между разными областями.

Полезный тест: изменение в одном контексте никогда не должно заставлять вас редактировать что-то в другом. Если это происходит, значит, границы разделения задач определены неверно.

Правило 2 — Модуль — это домен, а не слой

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

src/
├── controllers/   # order, auth, product, shipment, payment...
├── services/      # order, auth, product, shipment, payment...
├── entities/
└── enums/         # every enum in the whole app

Вы открываете папку controllers/, пролистываете её, пропуская разделы, связанные с аутентификацией и доставкой, чтобы найти контроллер заказов. Затем вы делаете то же самое в папках services/, entities/ и enums/. Четыре или пять папок, четыре или пять пролистываний — а функция, которую вы добавляете, разбросана по каждой из них.

Такая структура отвечает на вопрос «покажите мне все контроллеры», который на самом деле почти никто не задаёт. На самом деле люди спрашивают: «покажите мне всё, что связано с заказами». Поэтому сначала структурируйте код по областям:

modules/orders/
├── controllers/
├── dto/
├── entities/
│   ├── order.entity.ts
│   └── order-status.enum.ts   # the enum sits next to what it uses
├── repositories/
└── orders.module.ts

При такой структуре элементы для упаковки подарков находятся исключительно в одной папке. Обратите внимание: на верхнем уровне нет общей папки enums/ — энумерации должны находиться рядом с тем, что они описывают. Есть одно правило: папка common/ должна содержать только те элементы, которые не относятся ни к одной области, например, вспомогательные функции для пагинации или базовый класс репозитория. Как только папка common/ начинает содержать информацию о том, что такое заказ, она фактически превращается в ещё один модуль под другим названием.

Правило 3 — Модули должны зависеть от интерфейсов, а не от сервисов друг друга

Представьте, что две функции выпускаются в одном спринте. На странице продукта необходимо отображать информацию «3 открытых заказа», поэтому модуль catalog обращается к сервису OrderService. В то же время для выдачи квитанции требуются названия продуктов, поэтому модуль orders использует сервис ProductService. Nest отказывается объединять эти компоненты между собой:

Nest cannot create the CatalogModule instance.
- A circular dependency between modules. Use forwardRef() to avoid it.

Заключение процесса инъекции в функцию forwardRef() маскирует ошибку, но в то же время окончательно связывает два модуля воедино. Настоящее решение заключается в использовании небольшого интерфейса, определенного самим разработчиком, вместо обращения к сервисам другого модуля.

catalog должен запрещать удаление продукта, который все еще входит в открытый заказ, но только orders имеет эту информацию. Вместо импорта модуля orders, catalog просто определяет вопрос, на который ему нужен ответ:

export interface ProductUsageGuard {
  isProductInUse(productId: string): Promise<boolean>;
}

Модуль orders предоставляет ответ путем реализации этого интерфейса и регистрации себя, так что catalog может задавать вопросы, не импортируя ничего из orders:

for (const guard of this.guards) {
  if (await guard.isProductInUse(id)) throw new ProductInUseError(id);
}
Before:  catalog  ⇄  orders           circular — Nest won't boot
After:   catalog  ◄──implements──  orders   one way — catalog owns the interface

Теперь зависимости направлены в одном направлении, поэтому циклов нет, и не требуется использование функции forwardRef(). К тому же, если позже появится правило вроде «нельзя удалять продукт, связанный с активной подпиской», модуль подписок сможет зарегистрировать свой собственный механизм проверки, и catalog совсем не нужно будет меняться.

Правило 4 — Сохраняйте контроллеры простыми и позволяйте энтитетам выполнять логику

Рассмотрим правило вроде «нельзя отменить заказ, который уже отправлен». Где должна находиться эта логика? Во многих кодовых базах она оказывается там, где впервые возникла потребность в ней — обычно глубоко внутри какого-либо сервиса. Затем панель администратора требует ту же проверку, как и еженочная задача обработки данных, а в конце концов — и обработчик webhook. Каждое место реализует правило немного по-разному, кто-то забывает о четвертой копии, и в результате заказы, уже отправленные, начинают возвращаться с возмещением средств.

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

Вместо этого привяжите правило к объекту, который фактически управляет состоянием:

@Entity()
export class Order {
  status: OrderStatus = OrderStatus.DRAFT;
cancel(): void {
  if (this.status === OrderStatus.SHIPPED) {
    throw new Error('Cannot cancel an order that already shipped');
  }
  this.status = OrderStatus.CANCELLED;
}

Теперь существует ровно одно место, где определен метод «cancel», и у вызывающего кода нет никакой возможности обойти проверку — просто не существует альтернативного пути. Писать тесты на единицы без взаимодействия с базой данных крайне просто. Слой сервисов лишь координирует последовательность действий (order.cancel(), осуществление возврата средств, сохранение данных), в то время как контроллер практически перестает выполнять какие-либо функции:

@Post(':id/cancel')
cancel(@Param('id') id: string) {
  return this.orders.cancel(id);
}

Вот как выглядит разделение обязанностей в одном изображении:

HTTP ─► Controller ─► Service ─► Order  (the rules)
                         └─────► Repository ─► DB  (the queries)

Энтитеты обеспечивают соблюдение правил, репозитории обрабатывают запросы, сервисы координируют последовательность вызовов, а контроллеры занимаются исключительно HTTP-запросами.

Правило 5 — Проектируйте данные так, чтобы невозможно было существование недопустимых состояний

Правило 4 перенесло логику в энтитеты. Еще два паттерна завершают работу, каждый из них устраняет определенный класс ошибок.

Объекты значений управляют примитивами, содержащими правила. Общая сумма заказа — это просто число, поэтому ничто не мешает купону снизить её до отрицательного значения или возврату средств в евро поступить на заказ, оформленный в долларах США. Правила, определяющие понятие «денег», не находятся ни где-то конкретно. Чтобы решить эту проблему, необходимо предоставить деньгам собственный тип, который будет обеспечивать соблюдение этих правил:

export class Money {
  private constructor(readonly cents: number, readonly currency: string) {}

  static of(cents: number, currency: string): Money {
    if (cents < 0) throw new Error('Money cannot be negative');
    return new Money(cents, currency);
  }

  add(o: Money): Money {
    if (o.currency !== this.currency) throw new Error('Currency mismatch');
    return Money.of(this.cents + o.cents, this.currency);
  }
}

Благодаря этому создание отрицательной суммы или несоответствия валют больше невозможно — сам тип этого препятствует. Именно это и называется объектом значений: небольшой неизменяемый тип, идентифицируемый своим значением, а не идентификатором. Используйте его всякий раз, когда примитив сопровождается правилами, которые приходится проверять снова и снова — деньги, адреса электронной почты, диапазоны CIDR — но избегайте его в случае простого идентификатора без дополнительных правил.

Агрегаты реализуют правило, касающееся нескольких объектов. Общая сумма заказа всегда должна соответствовать сумме его элементов. Если у OrderLine будет свой репозиторий, рано или поздно кто-то сохранит элемент заказа без обновления родительского заказа, в результате чего сумма незаметно станет неверной. Решение заключается в том, чтобы с самого начала не допускать такой возможности: сделайте Order корнем агрегата — единственным объектом, который вы загружаете или сохраняете, единственной точкой входа в эту часть модели. OrderLineRepository не существует; элементы заказа изменяются исключительно через сам заказ:

addLine(sku: string, price: Money, qty: number): void {
  if (this.status !== OrderStatus.DRAFT) throw new Error('Order already placed');
  this.lines.push(new OrderLine(sku, price, qty));
  this.total = this.sumOfLines();
}

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

Правило 6 — публикуйте события вместо прямого вызова сервисов

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

Нужно изменить подход. Модуль orders выполняет свою работу и затем сообщает о результатах — он даже не знает, кто, если вообще кто-то, слушает его:

this.events.emit(new OrderPlaced(order.id, order.customerId, items));

Каждый заинтересованный компонент реагирует самостоятельно:

@OnEvent(OrderPlaced.name)
handle(e: OrderPlaced) { return this.shipping.createShipment(e); }

Добавление баллов лояльности сейчас подразумевает включение слушателя в модуль лояльности; таблица orders остается нетронутой. В отдельных сервисах та же идея применяется через посредника сообщений (например, RabbitMQ), с одним дополнительным мером предосторожности — транзакционной таблицей выходных сообщений. Вы записываете событие в таблицу outbox в той же транзакции, что и сохранение заказа, а затем отдельный рабочий процесс публикует его. Без этого шага сбой между сохранением заказа и публикацией события приводит к его незаметной потере — в производственных условиях это разница между надежной системой и системой, работающей с ошибками.

Одно предупреждение: события затрудняют понимание общего потока, поскольку нет единого места, где отображается полная последовательность происходящих действий. Используйте их для реакций, выходящих за пределы определенного контекста, а не для шагов, которые должны выполняться в рамках одной целостной задачи.

Преимущества

Использование принципов DDD выборочно не означает добавление дополнительных архитектурных слоев. Речь идет о том, чтобы разместить каждую логическую составляющую там, где она должна находиться: модели отвечают за правила, модули — за свои области, репозитории — за запросы, а события связывают один контекст с другим. Соблюдая эти границы, ваше приложение NestJS будет проще для понимания, модификации и развития со временем.

Связанные материалы

  • Почему маппинг TypeScript на основе рефлексии снижает производительность V8 — Объясняется, как скрытые классы и кэши V8 ухудшают свои показатели при использовании маппинга объектов на основе рефлексии, и как мономорфные функции, скомпилированные с помощью JIT, восстанавливают скорость работы API NestJS.