Реалізація Domain-Driven Design (DDD) для веб-додатку
DDD — методологія проектування, при якій архітектура системи відображає бізнес-домен. Код говорить тією ж мовою, що й експерти предметної області. Не «оновити запис у таблиці users», а «призупинити облік за порушення політики». DDD виправданий для складних доменів — електронної комерції з нетривіальними правилами ціноутворення, фінансових систем, SaaS з гнучкими тарифами.
Ubiquitous Language
Перший крок — створення єдиного словника з бізнес-експертом. Терміни кодуються в код безпосередньо.
Погано: updateUserStatus(userId, 1) або setActive(true)
Добре: customer.activate(), subscription.suspend(reason), order.submit()
Словник фіксується у документі та підтримується актуальним при змінах вимог.
Bounded Context
Велику систему ділять на Bounded Context — обмежені контексти, в кожному з яких терміни мають строго визначений сенс. «Продукт» у контексті Каталогу — це опис, фото, SEO. «Продукт» у контексті Замовлень — SKU, ціна, кількість. «Продукт» у контексті Складу — фізична одиниця з місцем зберігання.
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Catalog Context │ │ Orders Context │ │ Inventory Context │
│ │ │ │ │ │
│ Product │ │ OrderItem │ │ StockItem │
│ Category │ │ Order │ │ Warehouse │
│ PriceList │ │ Customer │ │ StockMovement │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
↑ Anti-Corruption Layer між контекстами
Явні межі та маппінг між контекстами. Context Map документує взаємодію.
Будівельні блоки
Entity — об'єкт з ідентичністю, збереженою при зміні атрибутів:
class Order {
private readonly _id: OrderId;
private _status: OrderStatus;
private _items: OrderItem[] = [];
private _domainEvents: DomainEvent[] = [];
constructor(id: OrderId, customerId: CustomerId) {
this._id = id;
this._status = OrderStatus.Draft;
this.raise(new OrderCreatedEvent(id, customerId));
}
addItem(product: Product, quantity: Quantity): void {
if (this._status !== OrderStatus.Draft) {
throw new OrderNotEditableError(this._id);
}
if (quantity.isZero()) {
throw new InvalidQuantityError();
}
const existing = this._items.find(i => i.productId.equals(product.id));
if (existing) {
existing.increaseQuantity(quantity);
} else {
this._items.push(new OrderItem(product.id, product.price, quantity));
}
}
submit(): void {
this.ensureCanTransitionTo(OrderStatus.Submitted);
if (this._items.length === 0) throw new EmptyOrderError();
this._status = OrderStatus.Submitted;
this.raise(new OrderSubmittedEvent(this._id, this.calculateTotal()));
}
get id(): OrderId { return this._id; }
get total(): Money { return this.calculateTotal(); }
pullDomainEvents(): DomainEvent[] { /* ... */ }
}
Value Object — об'єкт без ідентичності, визначуваний значеннями. Невіддільний:
class Money {
private constructor(
private readonly _amount: number,
private readonly _currency: Currency
) {
if (_amount < 0) throw new NegativeAmountError();
}
static of(amount: number, currency: Currency): Money {
return new Money(amount, currency);
}
add(other: Money): Money {
if (!this._currency.equals(other._currency)) {
throw new CurrencyMismatchError();
}
return new Money(this._amount + other._amount, this._currency);
}
multiply(factor: number): Money {
return new Money(Math.round(this._amount * factor * 100) / 100, this._currency);
}
equals(other: Money): boolean {
return this._amount === other._amount && this._currency.equals(other._currency);
}
}
class Email {
private constructor(private readonly value: string) {}
static create(email: string): Email {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new InvalidEmailError(email);
}
return new Email(email.toLowerCase());
}
}
Aggregate — кластер пов'язаних сутностей з однією точкою входу (Aggregate Root). Зовнішній код працює тільки з коренем:
// Order — Aggregate Root
// OrderItem — входить в агрегат Order, не доступний напрямку
class Order {
// ...все взаємодія з items тільки через Order
removeItem(productId: ProductId): void { /* ... */ }
updateQuantity(productId: ProductId, qty: Quantity): void { /* ... */ }
}
Правило: одна трансакція = один агрегат. Між агрегатами — eventual consistency через доменні події.
Domain Service — операція, яка не належить жодній сутності:
class OrderPricingService {
constructor(
private discountRepo: DiscountRepository,
private taxService: TaxCalculationService
) {}
async calculateTotal(order: Order, customer: Customer): Promise<PricingResult> {
const discounts = await this.discountRepo.findApplicable(
customer.segment, order.items
);
let subtotal = order.items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity.value)),
Money.zero(Currency.USD)
);
const discountAmount = this.applyDiscounts(subtotal, discounts, customer);
const taxAmount = await this.taxService.calculate(subtotal, customer.address);
return new PricingResult(subtotal, discountAmount, taxAmount);
}
}
Repository — абстракція доступу до сховища для агрегату:
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId, options?: FindOptions): Promise<Order[]>;
save(order: Order): Promise<void>;
delete(id: OrderId): Promise<void>;
}
// Реалізація відокремлена від інтерфейсу (інверсія залежностей)
class PostgresOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise<Order | null> {
const row = await this.db.queryOne(
'SELECT * FROM orders WHERE id = $1', [id.value]
);
return row ? this.toDomain(row) : null;
}
private toDomain(row: OrderRow): Order {
return Order.reconstitute({
id: OrderId.from(row.id),
status: OrderStatus[row.status],
customerId: CustomerId.from(row.customer_id),
items: row.items.map(this.itemToDomain)
});
}
}
Application Layer
Тонкий шар, що оркеструє доменні об'єкти. Не містить бізнес-логіки:
class PlaceOrderUseCase {
async execute(dto: PlaceOrderDto): Promise<PlaceOrderResult> {
const customer = await this.customerRepo.findById(
CustomerId.from(dto.customerId)
);
if (!customer) throw new CustomerNotFoundError(dto.customerId);
const order = new Order(OrderId.generate(), customer.id);
for (const item of dto.items) {
const product = await this.productRepo.findById(ProductId.from(item.productId));
order.addItem(product, Quantity.of(item.quantity));
}
const pricing = await this.pricingService.calculateTotal(order, customer);
order.applyPricing(pricing);
order.submit();
await this.orderRepo.save(order);
await this.eventBus.publishAll(order.pullDomainEvents());
return { orderId: order.id.value, total: order.total };
}
}
Шарова архітектура
Domain Layer — Entities, Value Objects, Aggregates, Domain Services, Events
Application Layer — Use Cases, Application Services, DTOs, Ports
Infrastructure Layer — Repositories (PostgreSQL/Redis), External APIs, Message Brokers
Presentation Layer — HTTP Controllers, GraphQL Resolvers, CLI Commands
Залежності спрямовані тільки всередину: Presentation → Application → Domain. Domain не залежить ні від чого.
Терміни реалізації
- Проектування домену, Context Map, Ubiquitous Language — 1–2 тижні
- Реалізація одного Bounded Context з 3–5 агрегатами — 3–5 тижнів
- Повна система з кількома контекстами та інтеграцією — 2–4 місяці







