Изображение, созданное 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!?

Комментарии (0)