Когда в команду приходят начинающие разработчики, а проект уже строился на архитектурных принципах, таких как Domain-Driven Design (DDD), иногда возникают сложности с их применением на практике. Даже при самых лучших намерениях результат может получиться далёким от идеала.
Мне не раз доводилось работать с проектами на NestJS, где DDD был задуман, но реализация оставляла вопросы: бизнес-логика оказывалась в контроллерах, сущности отвечали за доступ к базе данных, а Value Objects использовались скорее как формальность, без значимой роли в проекте.
На основе своего опыта я решил собрать несколько наиболее распространённых ошибок и дать простые рекомендации, которые помогут избежать подобных ситуаций. Моя цель - показать, как можно использовать подходы DDD в сочетании с архитектурой NestJS, сохраняя код ясным, структурированным и удобным для изменений.
"Domain-Driven Design" (DDD) - это подход к проектированию программного обеспечения, в центре которого находится предметная область (домен) и бизнес-логика. Суть DDD в том, чтобы создавать архитектуру, отражающую реальные бизнес-процессы, использовать единый язык общения со стейкхолдерами и структурировать код так, чтобы изменения в бизнес-требованиях минимально влияли на разработку.
В контексте JavaScript/TypeScript и фреймворков вроде NestJS (на бэкенде) у начинающих разработчиков часто возникает путаница: какие слои создавать, как выделять сущности, где хранить логику и как правильно организовать репозитории. В результате код либо чрезмерно усложнен, либо нарушает фундаментальные принципы DDD.
В этой статье мы:
Разберем ключевые DDD-концепции (слои, сущности, Value Objects, доменные сервисы, репозитории) применительно к TypeScript-проектам.
Покажем частые ошибки, которые совершают джуны при внедрении DDD в NestJS.
Расскажем, как этих ошибок избежать, демонстрируя на простых примерах.
Основные концепции DDD для JS/TS-разработчиков
Ниже приведён краткий обзор ключевых концепций, которые используются при построении архитектуры в духе Domain-Driven Design.
Слои (Layers)
В классическом DDD традиционно выделяют несколько слоёв. Для JavaScript/TypeScript-проектов (на Node.js/NestJS) их можно представить так:
-
Domain
Хранит доменные модели (Entities, Value Objects) и бизнес-логику (Domain Services).
Здесь описываются правила, которыми руководствуется бизнес, и основные операции над сущностями.
Обычно это набор классов (или функций), которые не зависят от инфраструктурных деталей (БД, внешние сервисы и т.п.).
-
Application (Use Cases)
Использует объекты Domain и работает с инфраструктурой (репозиторией, внешними сервисами) для достижения конкретных целей (Use Cases).
В NestJS это могут быть сервисы/провайдеры, которые знают, как вызвать нужный репозиторий, как orchestrate несколько операций.
-
Infrastructure
Отвечает за реализацию взаимодействия с базой данных, внешними сервисами (шлюз платежей, почтовые сервисы и т. д.).
Хранит конкретные реализации репозиториев, клиентов для HTTP-запросов и прочую низкоуровневую логику.
-
Interface (или Presentation)
Слой, который отвечает за взаимодействие с пользователем: UI-компоненты во фронтенде, REST-роуты или GraphQL-резольверы на бэкенде.
В NestJS это контроллеры (
@Controller()
)
Важная идея: каждый слой имеет свою зону ответственности и по возможности не пересекается напрямую с другими слоями. Таким образом, вы можете менять детали инфраструктуры (например, перейти с MongoDB на PostgreSQL) без глобального рефакторинга бизнес-логики.
Entities и Value Objects
Entity - объект, у которого есть уникальный идентификатор (например,
id
). Его состояние может меняться со временем (например, заказ может изменять статус).Value Object - объект без уникального идентификатора; он ценен своими значениями, а не личностью. Пример: Email, Money, Discount. Если в Email меняется одно из полей (например, адрес), мы обычно создаём новый объект Email, а не меняем старый.
В TypeScript мы можем оформлять Entity и Value Object как обычные классы. Например:
// Пример сущности (Entity)
class Order {
private id: string;
private status: OrderStatus;
private items: OrderItem[]; // OrderItem - может быть Value Object
constructor(id: string) {
this.id = id;
this.status = OrderStatus.DRAFT;
this.items = [];
}
// Бизнес-методы, меняющие состояние
addItem(item: OrderItem) { /* ... */ }
pay() { /* ... */ }
}
// Пример Value Object
class OrderItem {
constructor(
private readonly productId: string,
private readonly quantity: number,
private readonly price: number
) {
if (quantity <= 0) {
throw new Error('Quantity must be positive');
}
}
get total(): number {
return this.quantity * this.price;
}
}
Domain Services
Это класс (или набор функций), который содержит бизнес-логику, не привязанную напрямую к конкретной сущности. Пример: вычисление комиссии за транзакцию, где нужно учитывать несколько сущностей (пользователь, транзакция, тарифы и т.д.).
class CommissionService {
calculateCommission(user: User, transaction: Transaction): number {
// сложные бизнес-правила
return /* ... */;
}
}
Repository
Это посредник между доменными моделями и базой данных (или другими хранилищами). В DDD часто используют интерфейс репозитория в доменном слое, а реальную реализацию помещают в инфраструктурный слой.
// Domain (интерфейс репозитория)
export interface OrderRepository {
save(order: Order): void;
findById(id: string): Order | null;
}
// Infrastructure (реальная реализация)
@Injectable()
export class InMemoryOrderRepository implements OrderRepository {
private storage: Record<string, string> = {};
save(order: Order): void {
this.storage[order.getId()] = JSON.stringify(order);
}
findById(id: string): Order | null {
const data = this.storage[id];
return data ? JSON.parse(data) : null;
}
}
Частые ошибки новичков при внедрении DDD
Ниже перечислены некоторые распространённые ошибки, с которыми сталкиваются начинающие специалисты, пытаясь интегрировать DDD в NestJS-проекты.
Избыточная абстракция
Симптом: разработчик стремится по всем канонам оформить свой код, в итоге появляются десятки модулей, слоёв, классов и интерфейсов, которые лишь запутывают логику и усложняют поддержку.
Почему это происходит:
Желание сделать всё по учебнику без учёта реальных требований.
Непонимание, какие элементы DDD действительно нужны проекту, а какие нет.
Как проявляется в NestJS:
Создание слишком большого количества модулей, даже когда бизнес-домен очень небольшой.
Чрезмерное дробление сервисов (вместо одного Domain Service - три-четыре мелких).
Выделение Value Object там, где достаточно примитивных типов.
Нарушение принципа единственной ответственности (SRP)
Симптом: класс (будь то Entity или Service) начинает выполнять сразу несколько задач. Например, и осуществляет доступ к базе, и проверяет входные данные, и реализует бизнес-логику.
Почему это происходит:
Путают Application Layer (или Service в NestJS) с Domain Service.
Не умеют чётко разделить обязанности между слоями.
Как проявляется в NestJS:
Контроллер начинает содержать и бизнес-логику, и валидацию, и вызовы к базе данных напрямую.
Сервис смешивает в себе методы для чтения/записи (репозиторий) и бизнес-логику (доменные операции), превращаясь в комбайн.
Неправильная (или отсутствующая) граница контекста
Симптом: весь код складывается в один глобальный модуль, и никакого намёка на Bounded Context нет. В результате трудно понять, какой код к чему относится, и как части системы взаимодействуют.
Почему это происходит:
Неудобство или непонимание, как распределять функциональность по контекстам.
Желание упростить структуру, чтобы не городить огород из нескольких модулей.
Как проявляется в NestJS:
Есть один-единственный модуль
AppModule
, в котором лежит вся логика, даже если предметная область комплексная.Отсутствуют чёткие границы между разными модулями, отвечающими за разные поддомены.
Использование DDD чисто для галочки
Симптом: файл domain.ts
, в котором вроде бы описаны Entities, а по факту там класс ради класса. Или Value Object, который не содержит никакой логики, а просто один кортеж полей.
Почему это происходит:
При желании выглядеть хорошо в глазах руководства или коллег, разработчик механически создаёт доменные классы, не вникая, нужна ли там реальная бизнес-логика.
Как проявляется в NestJS:
Мнимый
DomainModule
, где всё сводится к DTO для контроллеров и пустым классам вместо настоящих Entities.
Складывание всей логики в контроллер
Симптом: пытаясь быстро выдать результат или не понимая, как распределить слои, джун начинает лепить всю логику прямо в контроллеры: валидацию, вычисления, взаимодействие с базой, конвертацию данных, бизнес-правила.
Почему это происходит:
Недостаток опыта в построении многослойной архитектуры.
Стремление сразу всё сделать в одном месте.
Как проявляется в NestJS:
Логика обработки запроса, проверки прав пользователя и даже работа с базой - всё в одном методе
@Controller()
.
Отсутствие языка домена
Симптом: разработчик не общается с бизнес-экспертами, не уточняет терминологию. Как итог, названия классов, методов, модулей не совпадают с реальными терминами предметной области и запутывают всех вокруг.
Почему это происходит:
Желание закодить побыстрее, без глубокой проработки бизнес-логики.
Недопонимание важности Ubiquitous Language.
Как проявляется в NestJS:
Сущности называются
EntityOne
,EntityTwo
вместоOrder
,Product
. Или модулиModuleA
,ModuleB
- непонятно, что в них лежит.
Как избежать этих ошибок, используя возможности NestJS
Ниже приведены рекомендации, которые помогут вам сделать код более аккуратным и соответствующим принципам DDD.
Грамотная организация модулей. Делите приложение на модули, отражающие разные бизнес-контексты (если домен достаточно велик). Например,
OrdersModule
,PaymentsModule
,UsersModule
. NestJS-модуль служит естественной границей для Bounded Context или поддомена. Это упрощает масштабирование и поддержку кода, так как каждая область ответственности изолирована.-
Разделяйте слои приложения
Контроллеры (Controllers) - принимают запрос, вызывают соответствующий сервис, возвращают ответ.
Сервисы приложения (Application Services) - обеспечивают поток данных между внешним миром (API, UI) и доменным слоем. Здесь могут решаться задачи оркестрации (вызывают несколько доменных сервисов или репозиториев последовательно).
Доменный слой (Domain Layer) - хранит в себе Entities, Value Objects, Domain Services (бизнес-операции, связанные с несколькими сущностями).
Инфраструктурный слой (Infrastructure Layer) - реализация конкретных репозиториев (доступ к базе данных, сторонним API и т. п.), провайдеры, интеграция с другими системами.
Используйте Value Objects там, где есть реальный смысл. Если в вашем коде есть сложный тип, такой как денежная сумма (валюта + значение), и вам необходимы операции преобразования, округления или проверки валидности, создайте для этого Value Object. Не используйте Value Object для простых типов, например, имени пользователя, если там нет особой логики проверки.
Соблюдайте принцип единой ответственности (SRP). Каждый класс должен выполнять одну задачу и выполнять её хорошо. Например, Entity отвечает за данные, Domain Service - за бизнес-логику, а Infrastructure Service - за взаимодействие с внешними системами. Используйте Dependency Injection в NestJS для четкого разделения обязанностей между провайдерами.
Поддерживайте единый язык домена. Работайте в тесном взаимодействии с бизнес-командой, чтобы понять, как называются сущности и какую функциональность они ожидают. Присваивайте классам, методам и модулям понятные и осмысленные названия, чтобы они были понятны не только разработчикам, но и доменным экспертам.
Не усложняйте сверх меры. Если ваш проект небольшой или является MVP, избегайте избыточной сложности в виде множества слоёв и паттернов. Добавляйте элементы DDD постепенно, по мере роста и усложнения доменной области, чтобы не перегружать архитектуру.
Небольшой пример структуры в NestJS
Рассмотрим упрощённую структуру модуля Заказы (OrdersModule), демонстрирующую организацию слоёв по DDD-принципам.
orders
├── application
│ └── order.service.ts # OrderApplicationService
├── domain
│ ├── entities
│ │ └── order.entity.ts # Order Entity
│ ├── services
│ │ └── order.domain-service.ts # (опционально) логика, не привязанная к конкретной сущности
│ └── value-objects
│ └── order-item.vo.ts # Пример Value Object
├── infrastructure
│ └── order.repository.ts # Реализация репозитория
├── interfaces
│ └── order.controller.ts # Контроллер для Orders
└── orders.module.ts
Пример Entity и Value Object
// domain/entities/order.entity.ts
export class Order {
private status: OrderStatus;
private items: OrderItem[];
constructor(private readonly id: string) {
this.status = OrderStatus.DRAFT;
this.items = [];
}
addItem(item: OrderItem) {
if (this.status !== OrderStatus.DRAFT) {
throw new Error('Cannot add items to a non-draft order');
}
this.items.push(item);
}
pay() {
if (this.items.length === 0) {
throw new Error('Cannot pay for an empty order');
}
this.status = OrderStatus.PAID;
}
// ... другие бизнес-методы
}
// domain/value-objects/order-item.vo.ts
export class OrderItem {
constructor(
readonly productId: string,
readonly quantity: number,
readonly price: number,
) {
if (quantity <= 0) {
throw new Error('Quantity must be positive');
}
}
get total(): number {
return this.quantity * this.price;
}
}
Пример репозитория и сервиса приложения
// infrastructure/order.repository.ts
import { Injectable } from '@nestjs/common';
import { Order } from '../domain/entities/order.entity';
@Injectable()
export class OrderRepository {
private storage: Record<string, 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;
}
}
// application/order.service.ts
import { Injectable } from '@nestjs/common';
import { OrderRepository } from '../infrastructure/order.repository';
import { Order } from '../domain/entities/order.entity';
import { OrderItem } from '../domain/value-objects/order-item.vo';
@Injectable()
export class OrderService {
constructor(private readonly orderRepository: OrderRepository) {}
createOrder(orderId: string): Order {
const order = new Order(orderId);
this.orderRepository.save(order);
return order;
}
addItem(orderId: string, productId: string, quantity: number, price: number): Order {
const order = this.orderRepository.findById(orderId);
if (!order) throw new Error('Order not found');
order.addItem(new OrderItem(productId, quantity, price));
this.orderRepository.save(order);
return order;
}
payOrder(orderId: string): Order {
const order = this.orderRepository.findById(orderId);
if (!order) throw new Error('Order not found');
order.pay();
this.orderRepository.save(order);
return order;
}
}
Контроллер (Interface Layer)
// interfaces/order.controller.ts
import { Controller, Post, Param, Body } from '@nestjs/common';
import { OrderService } from '../application/order.service';
@Controller('orders')
export class OrderController {
constructor(private readonly orderService: OrderService) {}
@Post('create/:id')
createOrder(@Param('id') orderId: string) {
return this.orderService.createOrder(orderId);
}
@Post(':id/add-item')
addItem(
@Param('id') orderId: string,
@Body() body: { productId: string; quantity: number; price: number },
) {
return this.orderService.addItem(orderId, body.productId, body.quantity, body.price);
}
@Post(':id/pay')
payOrder(@Param('id') orderId: string) {
return this.orderService.payOrder(orderId);
}
}
Модуль
// orders.module.ts
import { Module } from '@nestjs/common';
import { OrderService } from './application/order.service';
import { OrderController } from './interfaces/order.controller';
import { OrderRepository } from './infrastructure/order.repository';
@Module({
controllers: [OrderController],
providers: [OrderService, OrderRepository],
})
export class OrdersModule {}
Такой подход позволяет чётко определить роли разных компонентов и упростить сопровождение. При необходимости вы можете заменять OrderRepository
на более сложную реализацию (SQL, MongoDB, внешние API), не меняя код доменных сущностей.
Советы для упрощения внедрения DDD в NestJS
Не пытайтесь внедрить все паттерны из книги Эрика Эванса сразу. Начинайте с малого! Пусть архитектура растёт вместе с бизнес-требованиями.
Используйте чистые сущности. Доменные классы (Entities, Value Objects) желательно делать независимыми от фреймворка NestJS. Это упростит тестирование и переиспользование.
Чётко разделяйте слои! NestJS уже подталкивает к модульному разделению кода, пользуйтесь этим. Разделяйте Application Services и Domain Services, а также храните репозитории в инфраструктуре.
Согласовывайте термины с бизнес-экспертами и используйте эти же названия в коде. Следуйте Ubiquitous Language.
Проводите рефакторинг по мере роста. Требования и понимание домена будут меняться - будьте готовы адаптировать архитектуру.
Не стесняйтесь задавать вопросы❗️ Спрашивайте коллег, участвуйте в обсуждениях архитектуры, ищите подходящие решения под ваши конкретные задачи.
Итог
DDD - это не серебряная пуля, а набор гибких принципов, которые помогают сконцентрироваться на сути бизнеса и строить код, отражающий реальные процессы. В сочетании с NestJS, предоставляющим удобный механизм модулей, контроллеров и сервисов, можно выстроить логичную и поддерживаемую архитектуру.
Однако при неправильном или чрезмерно формальном подходе DDD может обернуться избыточными абстракциями, нарушением принципов SRP и хаосом в коде. Следуйте рекомендациям описанным в статье и тогда ваша кодовая база будет гибкой, расширяемой и понятной, а любые изменения в бизнес-требованиях обернутся лишь локальными правками в соответствующих областях системы.
Удачи в освоении Domain-Driven Design в NestJS!?
Комментарии (20)
anisimovih
10.01.2025 12:55Все красиво, пока не откроешь маппер, тк все эти слои нужно как-то связывать.
В минимальном примере из статьи уже 5 конвертаций только для ордера. Почти столько же для Order Item, а вложенные энтити в агрегате это вообще жесть. И это у нас ещё нет подтипов с вычисляемыми полями.
Controller -> Application
Application -> Domain
Domain -> Persistence
Persistence -> Domain
Domain -> Controller
Упрощённая инициализация в конструкторе доступна только пока параметры передаются массивом. Когда их с десяток, начинаешь теряться в последовательности. Очень легко нечаянно вторым параметром передать цену, а третьим количество. Поэтому или нужно переводить в объект и прописывать ещё один маппинг в теле конструктора, или добавлять фабрики.
Имхо конечно, но боли больше, чем выхлопа. Ии очень помогает писать ээтот бойлерплейт, но вот читать его приходится именно нам. Если уж делать по чистому ддд, то нужно брать заточенный фреймворк вроде EF Core.
andry36 Автор
10.01.2025 12:55Маппинг может казаться обременительным, но он помогает чётко отделить доменную модель от инфраструктуры и даёт гибкость при изменениях. Если домен сложный и будет расти, эта боль окупается. ORM (например, EF Core) тоже не всё делает по магии: да, для базового случая работает автоконфигурация, но при нетривиальных требованиях (сложные связи, собственные схемы, специальные правила маппинга) придётся немало прописывать вручную.
Для простых проектов DDD может быть избыточным, а при серьёзных требованиях маппинг - адекватная плата за структурированную архитектуру.
olku
10.01.2025 12:55Про реализацию DDD на Nestjs информации немного, а книг нет вовсе. Продолжайте. Некоторые моменты.
Интерфейс это часть Приложения - gui, cli, api. Приложение без интерфейса не имеет смысла.
DDD ориентирована на поведение, а не на данные. Сущности и Репозитории часто воспринимаются как хранилище вроде ORM. Сущность имеет место быть в домене, но попробуйте без нее, сосредоточив логику в сервисе. Возможно тогда уйдет pay из Order.
Используйте Value objects. Попробуйте разрешить атомарные типы только в них, удивитесь как преобразится код.
SvyatoslavS
10.01.2025 12:55Сущность имеет место быть в домене, но попробуйте без нее, сосредоточив логику в сервисе.
Поясните, пожалуйста, Вы про анимичную модель или про что-то другое? Если второе, то расскажите подробнее, пожалуйста.
olku
10.01.2025 12:55Да, вроде нее. Новичок может подумать что проект всегда надо структурировать как в статье. Идеально бы посмотреть проект целиком и оценивать его инварианты структуры в масштабе.
SvyatoslavS
10.01.2025 12:55Мне, анимичная модель не близка, но и необходимость сервисов очевидна. Я за подход описанный классиками: если логика затрагивает одну сущность, скорее всего, ей место в методе класса этой сущности, а доменные сервисы нужны, если изменяются несколько сущностей-агрегатов или просто автономных сущностей (это важно, потому, что изменением подчиненных сущностей, вероятно, должен заниматься метод агрегата). Мне кажется, инкапсулируя поведение и соблюдение инвариантов сущности в нее саму, мы существенно упрощаем логику внешних сервисов: сервис знает интерфейс сущности, и программисту сервиса нет необходимости думать о реализации этой логики и, возможно, даже знать о ней. Это освобождает оперативную память в голове )
andry36 Автор
10.01.2025 12:55Спасибо за комментарий!
Интерфейс не всегда является обязательным элементом. Существуют сценарии, где главная ценность - это API или служебная библиотека. Сущности и репозитории часто действительно сводят к хранению ради удобства ORM, что может отдалять от исходных целей DDD. Перенос логики в сервисы может облегчить сущности, но усложнить связь между ними и состоянием, если бизнес-правила тесно привязаны к жизненному циклу объекта. Value Objects сокращают использование примитивов и делают код выразительнее, однако важно соблюдать баланс, чтобы не превратить проект в набор мелких обёрток.olku
10.01.2025 12:55Написал выше в комментарии что важно видеть проект целиком. У DDD есть версия Lite, которая принимается, когда команда не вывозит больше когнитивную сложность или проблемную область. Возврат техдолга, другими словами. Второе практическое применение DDD - блеснуть на техническом интервью. Заметил, что многие техлиды любят DDD, не используя его у себя.
Я бы из quantity тоже сделал value object для демонстрации factory fromString, встроенной валидации, которую куда только не ставят, и bounded context, когда в домене корзины разрешены положительные инты, а в домене склада возможны и нули.
shadowphoenix
10.01.2025 12:55В целом это все применимо и к java/kotlin. Но разделять лук и домены нужно.
SvyatoslavS
10.01.2025 12:55Я, честно, еще далек от как полного понимания ДДД так и от его реализации при помощи многослойных архитектур (события, транзакции через единицы работы, определение границ агрегатов и другие моменты - все это вызывает больше вопросов, чем является для меня рабочим инструментом, тем более что, для условий 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, чтобы не смешивать с сервисом, который будет реализовывать логику связанную со списанием/зачислением денег и не путать других читателей-комментаторов ).
andry36 Автор
10.01.2025 12:55Спасибо за фидбек!
1) Я определяю интерфейс репозитория в доменном слое, чтобы выразить, какие операции нужны для работы с доменными объектами, но при этом реальную работу (вызовы save(), findById() и тд) провожу из приложения (use cases). Так домен остаётся чистым и не зависит от инфраструктуры, а слой приложения решает, когда именно обращаться к репозиторию. В статье это не опечатка, а просто демонстрация того, что интерфейс живет рядом с доменными сущностями, а использование - на уровне приложения.2) Я не призываю полностью "очищать" сущности от логики. Если метод напрямую меняет состояние или проверяет инварианты (например, order.markAsPaid() с проверкой статуса), то ему место в сущности. Если нужно задействовать инфраструктуру или другие сущности, лучше вынести это в сервис. В статье я критиковал ситуации, где сущность - это просто набор геттеров/сеттеров без логики.
3) Бывает полезно заворачивать простые типы в ValueObject, чтобы защититься от ошибок и явно отразить бизнес-понятийный уровень. Считаю это своего рода "логикой", поскольку мы запрещаем смешивать несовместимые типы. Однако, если никакого смысла и валидации в VO нет, можно оставить примитив.
4) Вы правы, лучше назвать markAsPaid(), чтобы сразу отражать суть. Если там нет процесса списания денег, то название pay() может путать. Если же есть реальная финансовая логика, тогда это уже задача сервиса, а сущность лишь меняет свой статус.
olku
10.01.2025 12:55Вот про "меняет свой статус" интересно. Это уже другой объект в памяти, который не может быть равным предыдущему по equals, верно? Это нормально в паттерне Active Record в ORM внутри инфраструктуры, но не в домене, по моему мнению.
SvyatoslavS
10.01.2025 12:55Я определяю интерфейс репозитория в доменном слое, чтобы выразить, какие операции нужны для работы с доменными объектами, но при этом реальную работу (вызовы save(), findById() и тд) провожу из приложения (use cases).
Получается, в домене лежит интерфейс, который доменом не используется, почему бы не положить его на уровне приложения? )
mike_shapovalov
10.01.2025 12:55Спасибо, очень полезная статья с простыми и понятными примерами, которые можно использовать как вводный материал для разработчиков (причем не только JS), которые ранее не сталкивались с DDD. Добавил в закладки.
mike_shapovalov
10.01.2025 12:55Единственное что я бы изменил это группировку классов в слое. С моей точки зрения удобнее группировать по доменным агрегатам а не по типу классов. Т.е.
domain order OrderReposityInterface.ts Order.ts OrderService.ts OrderItem.ts
Ну и интерфейс репозитория с моей точки зрения должен объявляться в слое приложения. На моей практике не было не одного случая, когда репозиторий был нужен в слое домена.
Cobalt
10.01.2025 12:55Какие все умные на таких синтетических примерах. А когда у вас есть тайпорм энтити, домейн энтити, дто - одни мапперы задолбаешься писать. Постоянный бойлерплейт, куча повтопяемых полей. Когда уже есть готовая бд с автоинкрементным айди - попробуй создай доменную сущьность вне репозитория!
Cmuser
10.01.2025 12:55Чем 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; }
Dr10s
Самоя популярная ошибка в начале статьи. DDD (ни тактический, ни стратегический) не слышал никогда о каких то слоях..
Вы смешиваете DDD и луковичную(слоистую) архитектуру - не надо так.
andry36 Автор
Спасибо за комментарий!
DDD сам по себе не навязывает луковичную или слоистую архитектуру — это просто один из удобных способов изолировать доменную логику от инфраструктуры. В книге Эванса упомянуто разделение на «application» и «domain», но нигде не сказано «слой обязателен». В статье я показал именно такой подход, потому что в реальных NestJS-проектах он часто упрощает поддержку. Но да, формально DDD не требует какой-то одной архитектурной схемы.