Перейти к содержимому

17. Domain-Driven Design

Domain-Driven Design — подход к проектированию сложного программного обеспечения, ставящий в центр бизнес-домен и его логику, а не технические детали. Автор концепции — Эрик Эванс, книга «Domain-Driven Design» (2003).


Код должен отражать бизнес-язык. Когда разработчик и менеджер говорят об «Заказе», «Пользователе», «Платеже» — в коде должны быть классы Order, User, Payment с соответствующим поведением.

«Если твой код не может объяснить бизнесу что он делает — это плохой код»


Первый и главный принцип DDD — единый язык для всех участников проекта:

// ❌ Технический язык в коде
class DataRecord {
processEntry(entryId: string, statusCode: number): void { }
}
// ✅ Язык бизнеса в коде
class Order {
confirmPayment(paymentId: string): void { }
shipToAddress(address: ShippingAddress): void { }
cancel(reason: CancellationReason): void { }
}

Объект с уникальной идентичностью, которая не меняется в течение жизни объекта:

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; }
}

Объект без собственной идентичности, описывающий аспект домена. Два 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; }
}

Кластер связанных объектов, которые рассматриваются как единое целое. Имеет «Aggregate Root» — главную сущность:

// Order — Aggregate Root
class 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'),
);
}
}

Бизнес-логика которая не принадлежит конкретной сущности:

// Перевод денег — не принадлежит ни аккаунту-отправителю, ни получателю
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();
}
}

События, описывающие что произошло в домене:

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;
}
}

Абстракция для работы с агрегатами:

interface OrderRepository {
findById(id: OrderId): Promise<Order>;
findByUserId(userId: UserId): Promise<Order[]>;
save(order: Order): Promise<void>;
delete(id: OrderId): Promise<void>;
}

Чёткие границы, внутри которых применяется единый язык. За пределами — другой контекст, другой язык:

[Sales Context] [Shipping Context]
Customer Customer (другая модель!)
Order Shipment
Product Package

  1. Реализуй Value Object PhoneNumber с валидацией и форматированием (+7 XXX XXX-XX-XX).

  2. Создай Aggregate BlogPost с Entity Comment внутри. Бизнес-правила: нельзя комментировать архивный пост, max 100 комментариев.

  3. Спроектируй Domain Service PasswordChangeService с проверкой истории паролей.

  4. В чём разница между Entity и Value Object? Является ли Address Entity или Value Object?

  5. Прочитай первые 4 главы книги «Domain-Driven Design Distilled» Вона Вернона — она доступнее оригинала Эванса.