Шысь правіл DDD для структуравання домэнаў у прыкладах праграмавання NestJS
Выучыце шас практычных правіл дизайна, адмованага да домэну, для арганізавання модуляў, энтытаў і запускаў у NestJS, каб функцыі заставаліся ізольаванымі та зручнымі для адтрымкі.
Пасля паўгода працы з большасцю кодавых баз NestJS пачынаецца вядомая проблема. Вы дадаўце адзін поле, каб падтрымаць адну функцыя, і тэст у абсалютна некалявай частцы прыложэння раптам перстанае работаць. Кто-небудзь з команды спытае, дзе на самай працоўнае логіка, і чыстая адпаведзь — «распрашчана, часткова паўсюды».
Гэта не ўскрык сляпагінства ў працы над кодам. Часта гэта знак таго, што код быў арганізаваны на адной тэхнічнай сляйцы, а не ўжо на концэптых елементах, якія ён представляе. Полны падход Domain-Driven Design — глыбока практыка, якую большасць команд ніколі не застаўляе ў всій меры. Чыго будзе далей — гэта болей лёгкі падход: шасць правілаў, якія дзейсна прыносяць практычныя выгоды ў Nest-прыложэннях, без непатрэбных формальнасцей. Уважайце гэта як селектыўны DDD.
Правіла 1 — Не ляцуце, каб адзін модэль служыў усьму прыложэнню
Практычна кожны зусім складны задній фон мае ў своёй сэрцы так званы «об’ект-бог». Часта яго называюць Order; у ёму дзесяткі столбцоў, якія могу быть пустымі, і палова кодавой базы імпортуе яго. У канцэ наследкам змена, здзейснена для каманды складу, таямна нарабляе проблемы пад час процесу оплаты.
Этот самы Order насправды ўскладнены з трохо разных аспектаў, якія хаваюцца пад адним іменем:
- Checkout каляквае пра цены, зніжкі і намеры платежу.
- Fulfillment каляквае пра SKU-ы і адресу доставкі, і не каляквае пра зніжкі.
- Billing каляквае пра суму і номер рахунку-фактуры.
Калі адна класа прабуеяць задовольніць усе тры аспекты, поле з зніжкай востраць опынаецца пры самой адресе доставкі. Змяніце аднае — і вы рызыкуеце нарабіць проблемы ў іншых двух.
Рашэнь — даць кожнаму рэгіону свой саблог і парадкавацься между ямі через пашткі, а не за дапамою спяльнага класу.
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/ — элементы типу enum должны знаходзіцца рэкста ўсередине таго, што яны описваюць. Едынай правілом, якое трэба сягладаць, є тое, што папка 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 павінен запобiec выдаленню продукту, які ўсё ще належыць да адкрытага замовлення — але толькі 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. Вы запісвайте заўданне ў табелю outbox у той жа транзакцыі, калі зберагаецца замовленне, а пасля цього окольны працоўнік яго публікуе. Без гэтага крока збой межы збераганням замовлення і публікацыёю заўданняя таямна выключае яго — у прымэтных умовах гэта стварае розніцу межы надзеяным системам і системам, якая страждае ад проблем.
Адна парада: заўданні затьмарваюць загальны ход роботы, адколі няма жаднага месца, яке паказваў бы весь ляміт таго, што выканана. Іспользуйце іх толькі для рэакцый, якія пераходзяць межы контексту, а не для крокаў, якія належаць да адной цэласнае задачы.
Прыбутак
Селектываўское застосоўванне DDD не значыць дадаўшчае нарабатанне архітектуры. Цэлі — разместыць кожную частку логіки там, дзе яй належы: модэлі керуюць правіламі, модулі — сваімі домэнамі, репазітарыі — запитамі, а з’явы спаўнаюць звязак між разнымі контэкстамі. Дазвольце гэтым межам застацца непрызначанымі, і ваша аплікацыя NestJS будзе простейшая для розумэння, змены і развіцця з часам.
Супаўзвязаныя матэрыялы
- Чаму NestJS ўспэшна працюе для команд і кодавых баз, якія растуць — Дакладнае выясненне таго, як структура NestJS, ввод залежнасцяў і дизайн на адной базе TypeScript дапамагаюць інжынерскім командам расшырвацца без падачы ў хаос.