17. Domain-Driven Design
Design Patterns. Урок: Domain-Driven Design (DDD)
Заголовок раздела «Design Patterns. Урок: Domain-Driven Design (DDD)»Domain-Driven Design — подход к проектированию сложного программного обеспечения, ставящий в центр бизнес-домен и его логику, а не технические детали. Автор концепции — Эрик Эванс, книга «Domain-Driven Design» (2003).
Ключевая идея
Заголовок раздела «Ключевая идея»Код должен отражать бизнес-язык. Когда разработчик и менеджер говорят об «Заказе», «Пользователе», «Платеже» — в коде должны быть классы Order, User, Payment с соответствующим поведением.
«Если твой код не может объяснить бизнесу что он делает — это плохой код»
Ubiquitous Language (Единый язык)
Заголовок раздела «Ubiquitous Language (Единый язык)»Первый и главный принцип DDD — единый язык для всех участников проекта:
// ❌ Технический язык в кодеclass DataRecord { processEntry(entryId: string, statusCode: number): void { }}
// ✅ Язык бизнеса в кодеclass Order { confirmPayment(paymentId: string): void { } shipToAddress(address: ShippingAddress): void { } cancel(reason: CancellationReason): void { }}Строительные блоки DDD
Заголовок раздела «Строительные блоки DDD»1. Entity (Сущность)
Заголовок раздела «1. Entity (Сущность)»Объект с уникальной идентичностью, которая не меняется в течение жизни объекта:
class User { private readonly id: UserId; private email: Email; private name: UserName; private role: UserRole;
constructor(id: UserId, email: Email, name: UserName) { this.id = id; this.email = email; this.name = name; this.role = UserRole.CUSTOMER; }
// Бизнес-логика принадлежит сущности promoteToAdmin(): void { if (this.role === UserRole.ADMIN) { throw new DomainError('User is already an admin'); } this.role = UserRole.ADMIN; }
changeEmail(newEmail: Email): void { if (this.email.equals(newEmail)) return; this.email = newEmail; // Можно выбросить доменное событие }
// Равенство по идентификатору equals(other: User): boolean { return this.id.equals(other.id); }
getId(): UserId { return this.id; } getEmail(): Email { return this.email; }}2. Value Object (Объект-значение)
Заголовок раздела «2. Value Object (Объект-значение)»Объект без собственной идентичности, описывающий аспект домена. Два Value Objects равны, если у них одинаковые атрибуты:
class Money { constructor( private readonly amount: number, private readonly currency: string, ) { if (amount < 0) throw new DomainError('Amount cannot be negative'); if (!['USD', 'EUR', 'RUB'].includes(currency)) { throw new DomainError(`Unsupported currency: ${currency}`); } }
add(other: Money): Money { if (this.currency !== other.currency) { throw new DomainError('Cannot add different currencies'); } return new Money(this.amount + other.amount, this.currency); }
multiply(factor: number): Money { return new Money(this.amount * factor, this.currency); }
equals(other: Money): boolean { return this.amount === other.amount && this.currency === other.currency; }
toString(): string { return `${this.amount} ${this.currency}`; }}
class Email { private readonly value: string;
constructor(value: string) { if (!value.includes('@')) throw new DomainError(`Invalid email: ${value}`); this.value = value.toLowerCase().trim(); }
equals(other: Email): boolean { return this.value === other.value; }
toString(): string { return this.value; }}3. Aggregate (Агрегат)
Заголовок раздела «3. Aggregate (Агрегат)»Кластер связанных объектов, которые рассматриваются как единое целое. Имеет «Aggregate Root» — главную сущность:
// Order — Aggregate Rootclass Order { private readonly id: OrderId; private readonly userId: UserId; private items: OrderItem[] = []; private status: OrderStatus = OrderStatus.PENDING; private readonly createdAt: Date;
constructor(id: OrderId, userId: UserId) { this.id = id; this.userId = userId; this.createdAt = new Date(); }
// Вся бизнес-логика через Aggregate Root addItem(productId: ProductId, quantity: number, price: Money): void { if (this.status !== OrderStatus.PENDING) { throw new DomainError('Cannot modify confirmed order'); }
const existingItem = this.items.find(item => item.productId.equals(productId)); if (existingItem) { existingItem.increaseQuantity(quantity); } else { this.items.push(new OrderItem(productId, quantity, price)); } }
confirm(): void { if (this.items.length === 0) { throw new DomainError('Cannot confirm empty order'); } if (this.status !== OrderStatus.PENDING) { throw new DomainError('Order is already confirmed'); } this.status = OrderStatus.CONFIRMED; }
calculateTotal(): Money { return this.items.reduce( (total, item) => total.add(item.getSubtotal()), new Money(0, 'USD'), ); }}4. Domain Service
Заголовок раздела «4. Domain Service»Бизнес-логика которая не принадлежит конкретной сущности:
// Перевод денег — не принадлежит ни аккаунту-отправителю, ни получателюclass MoneyTransferService { async transfer( fromAccountId: AccountId, toAccountId: AccountId, amount: Money, ): Promise<TransferId> { const [fromAccount, toAccount] = await Promise.all([ this.accountRepo.findById(fromAccountId), this.accountRepo.findById(toAccountId), ]);
if (!fromAccount.hasSufficientFunds(amount)) { throw new InsufficientFundsError(fromAccountId, amount); }
fromAccount.debit(amount); toAccount.credit(amount);
const transfer = new Transfer(fromAccountId, toAccountId, amount);
await this.accountRepo.save(fromAccount); await this.accountRepo.save(toAccount); await this.transferRepo.save(transfer);
return transfer.getId(); }}5. Domain Events
Заголовок раздела «5. Domain Events»События, описывающие что произошло в домене:
interface DomainEvent { readonly eventId: string; readonly occurredAt: Date; readonly aggregateId: string;}
class OrderConfirmedEvent implements DomainEvent { readonly eventId = crypto.randomUUID(); readonly occurredAt = new Date();
constructor( readonly aggregateId: string, readonly orderId: string, readonly userId: string, readonly total: Money, ) {}}
// Aggregate выбрасывает событияclass Order { private domainEvents: DomainEvent[] = [];
confirm(): void { // ... логика ... this.status = OrderStatus.CONFIRMED; this.domainEvents.push(new OrderConfirmedEvent( this.id.toString(), this.id.toString(), this.userId.toString(), this.calculateTotal(), )); }
pullDomainEvents(): DomainEvent[] { const events = [...this.domainEvents]; this.domainEvents = []; return events; }}6. Repository
Заголовок раздела «6. Repository»Абстракция для работы с агрегатами:
interface OrderRepository { findById(id: OrderId): Promise<Order>; findByUserId(userId: UserId): Promise<Order[]>; save(order: Order): Promise<void>; delete(id: OrderId): Promise<void>;}Bounded Context
Заголовок раздела «Bounded Context»Чёткие границы, внутри которых применяется единый язык. За пределами — другой контекст, другой язык:
[Sales Context] [Shipping Context]Customer Customer (другая модель!)Order ShipmentProduct PackageПрактические задания
Заголовок раздела «Практические задания»-
Реализуй Value Object
PhoneNumberс валидацией и форматированием (+7 XXX XXX-XX-XX). -
Создай Aggregate
BlogPostс EntityCommentвнутри. Бизнес-правила: нельзя комментировать архивный пост, max 100 комментариев. -
Спроектируй Domain Service
PasswordChangeServiceс проверкой истории паролей. -
В чём разница между Entity и Value Object? Является ли
AddressEntity или Value Object? -
Прочитай первые 4 главы книги «Domain-Driven Design Distilled» Вона Вернона — она доступнее оригинала Эванса.