Когда в команду приходят начинающие разработчики, а проект уже строился на архитектурных принципах, таких как 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!🚀
ссылка на оригинал статьи https://habr.com/ru/articles/871494/
Добавить комментарий