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

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

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

1991 слів

Через півроку роботи з більшістю проектів на NestJS з’являється знайома проблема. Ви додаєте одне поле для підтримки певної функціональності, і тест у зовсім не пов’язаній частині додатку раптово починає працювати некоректно. Хтось у команді запитує, де саме знаходиться логіка обробки замовлень, і чесна відповідь — «розкидана, трохи скрізь».

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

Правило 1 — Не змушуйте одну модель обслуговувати весь додаток

Майже кожна складна система ззаду має у своєму центрі так званий об’єкт-бог. Його часто називають Order; він має десятки стовпців, які можуть бути null, і половина кодової бази його імпортує. Зрештою зміна, зроблена для команди складу, тихо ламає процес оформлення замовлення.

Цей єдиний об’єкт Order насправді є поєднанням трьох різних функцій, прихованих під однією назвою:

  • Оформлення замовлення стосується цін, знижок та наміру здійснити оплату.
  • Виконання замовлення стосується SKU та адреси доставки, але не має нічого спільного зі знижками.
  • Бухгалтерський облік стосується суми та номера рахунку-фактури.

Коли один клас намагається виконувати функції всіх трьох, поле зі знижкою опиняється поруч із адресою доставки. Змінивши одне, ви ризикуєте пошкодити два інші елементи.

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

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

За наявності однієї вхідної точки неможливо випадково порушити цю інваріантність. Тримайте свої агрегати якомога меншими — лише те, що дійсно потребує змін у межах однієї транзакції — та посилайтеся на інші агрегати за їхніми ID, замість того щоб тримати прямі посилання на об’єкти.

Правило 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.