Когда в команду приходят начинающие разработчики, а проект уже строился на архитектурных принципах, таких как 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!?