DDD на TypeScript: Чистая архитектура для сложных проектов

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему обычный CRUD убивает ваш проект, и как DDD может это исправить

Каждый разработчик хотя бы раз сталкивался с ситуацией, когда простой на первый взгляд проект превращается в «болото» из сервисов, контроллеров и размазанной по всему коду бизнес-логики. Вы начинаете писать UserService, потом OrderService, а через полгода в OrderService лежит логика отправки email, расчёта скидок и проверки баланса пользователя. Знакомо?

Проблема в том, что мы слишком рано думаем о базах данных, фреймворках и REST API. Мы забываем про главное — про предметную область (domain). Domain-Driven Design (DDD) предлагает радикально иной подход: сначала моделируем бизнес-процессы на языке экспертов, а уже потом — код.

В этой статье мы разберём, как применять Domain-Driven Design в TypeScript на реальных примерах. Вы увидите, как строить чистую архитектуру, где бизнес-правила живут отдельно от инфраструктуры, а код легко тестировать и изменять.

Основы DDD: язык, контексты и модель

Ubiquitous Language (Единый язык)

Первый принцип DDD — это единый язык, понятный и разработчикам, и бизнес-экспертам. Если менеджер говорит «Клиент активировал подписку», то в коде должен быть метод activateSubscription(), а не setStatus(1). TypeScript с его строгой типизацией идеально подходит для этого — названия классов, методов и переменных становятся документацией.

Bounded Context (Ограниченный контекст)

Большая система разбивается на независимые контексты: «Платежи», «Управление пользователями», «Доставка». В каждом контексте своя модель и свой язык. TypeScript модули и strict-типы помогают жёстко разделять эти контексты на уровне импортов.

Строим доменную модель на TypeScript

Value Object (Объект-значение)

Это неизменяемая сущность, которая определяется своими атрибутами, а не идентификатором. Пример: Email, Money, Coordinates.

// value-objects/Email.ts
export class Email {
  private readonly value: string;

  constructor(email: string) {
    if (!this.isValid(email)) {
      throw new Error('Invalid email address');
    }
    this.value = email;
  }

  private isValid(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  public getValue(): string {
    return this.value;
  }

  public equals(other: Email): boolean {
    return this.value === other.value;
  }
}

Обратите внимание: Email сам проверяет свою валидность. Никакой сервис не должен знать, как выглядит правильный email. Это чистое Domain-Driven Design в TypeScript — бизнес-правило живёт внутри объекта.

Entity (Сущность)

В отличие от Value Object, сущность имеет идентификатор (ID) и может меняться во времени. Пример: User, Order.

// entities/User.ts
import { Email } from '../value-objects/Email';
import { UserId } from '../value-objects/UserId';

export class User {
  private readonly id: UserId;
  private email: Email;
  private isActive: boolean;

  constructor(id: UserId, email: Email) {
    this.id = id;
    this.email = email;
    this.isActive = true;
  }

  public getId(): UserId {
    return this.id;
  }

  public changeEmail(newEmail: Email): void {
    if (!this.isActive) {
      throw new Error('Cannot change email for inactive user');
    }
    this.email = newEmail;
  }

  public deactivate(): void {
    this.isActive = false;
  }
}

Aggregate (Агрегат)

Агрегат — это группа сущностей и value-объектов, которые обрабатываются как единое целое. У агрегата есть корень (Aggregate Root), через который происходит доступ к внутренним объектам.

Пример: заказ (Order) — это агрегат, который содержит список позиций (OrderItem). Вы не можете добавить позицию напрямую, только через метод addItem().

Repository Pattern: абстракция над данными

Репозиторий — это коллекция объектов, которая скрывает детали хранения (база данных, файл, API). В DDD репозиторий работает только с агрегатами. Для TypeScript это отличная возможность использовать интерфейсы.

// repositories/IUserRepository.ts
import { User } from '../entities/User';
import { UserId } from '../value-objects/UserId';

export interface IUserRepository {
  findById(id: UserId): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: UserId): Promise<void>;
}

Теперь реализация может быть любой: PostgreSQL, MongoDB, in-memory. Главное — код домена ничего не знает о конкретной базе данных. Это и есть чистая архитектура.

// infrastructure/PostgresUserRepository.ts
import { IUserRepository } from '../repositories/IUserRepository';
import { User } from '../entities/User';
import { UserId } from '../value-objects/UserId';

export class PostgresUserRepository implements IUserRepository {
  async findById(id: UserId): Promise<User | null> {
    // SQL запрос, маппинг результата в User
    return null;
  }

  async save(user: User): Promise<void> {
    // INSERT или UPDATE
  }

  async delete(id: UserId): Promise<void> {
    // DELETE FROM users WHERE id = ...
  }
}

Domain Services: логика, которая не вписывается в сущность

Иногда операция затрагивает несколько агрегатов. Например, перевод денег между счетами. Эту логику нельзя повесить на один агрегат — нужен сервис домена.

// services/TransferService.ts
import { Account } from '../entities/Account';
import { Money } from '../value-objects/Money';

export class TransferService {
  public transfer(from: Account, to: Account, amount: Money): void {
    from.withdraw(amount);
    to.deposit(amount);
    // Здесь можно добавить событие домена (Domain Event)
  }
}

Обратите внимание: сервис работает только с доменными объектами, без ORM, HTTP-запросов и прочей инфраструктуры. Это позволяет легко покрыть его юнит-тестами.

События домена (Domain Events) — реактивность без связывания

Когда в домене происходит что-то важное (заказ оплачен, пользователь зарегистрирован), мы генерируем событие. Другие части системы могут подписаться на него и выполнить свои действия (отправить email, обновить статистику).

// events/OrderPlacedEvent.ts
export class OrderPlacedEvent {
  constructor(
    public readonly orderId: string,
    public readonly totalAmount: number,
    public readonly customerEmail: string
  ) {}
}

Генерация события происходит внутри агрегата:

// entities/Order.ts
import { OrderPlacedEvent } from '../events/OrderPlacedEvent';

export class Order {
  private events: any[] = [];

  public place(): void {
    // бизнес-логика
    this.events.push(
      new OrderPlacedEvent(this.id, this.total, this.customer.email)
    );
  }

  public pullEvents(): any[] {
    const events = this.events                

Блоги

Рекомендаций по книгам