Почему обычный 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