DDD против реальности: распространённые ловушки и их решение в NestJS

от автора

 Изображение, созданное DALL-E

Изображение, созданное DALL-E

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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *