Реалізація Domain-Driven Design (DDD) для веб-застосунку
Ми стикалися з проєктом, де бізнес-логіка була розмазана по контролерах і сервісах. Кожна зміна вимагала годин пошуку, хто де оновлює статус замовлення. DDD вирішив цю проблему, зробивши код відображенням бізнес-процесів. Нижче — наш досвід реалізації DDD для веб-застосунків з прикладами на TypeScript.
Domain-Driven Design — методологія проєктування ПЗ, при якій архітектура системи відображає бізнес-домен. Код говорить тією ж мовою, що й експерти предметної області. Не «оновити запис у таблиці users», а «заблокувати акаунт за порушення політики». DDD виправданий для складних доменів — e-commerce з нетривіальними правилами ціноутворення, фінансових систем, SaaS з гнучкими тарифами. Ми застосовували DDD у проєктах з вимогами високої цілісності та частими змінами. Наші інженери мають 7+ років досвіду в DDD-проєктах і виконали понад 40 впроваджень для клієнтів з різних галузей. Domain-Driven Design — це не панацея, але потужний інструмент для складних систем.
Коли DDD необхідний
Якщо ваш проєкт обробляє тисячі замовлень з нестандартними правилами знижок, податками та обмеженнями — DDD допомагає структурувати хаос. Наприклад, в одному з проєктів ми зменшили час на внесення змін у логіку ціноутворення в 3 рази, а кількість помилок при релізах знизилася на 70%.
Управління складністю за допомогою DDD
DDD вводить чіткі межі — Bounded Context. Усередині кожного контексту терміни мають строгий сенс. Наприклад, «Продукт» у каталозі — це опис і фото, а в замовленнях — SKU та ціна. Контексти взаємодіють через антикорупційний шар. Це ізолює зміни та прискорює розробку. Ерік Еванс, Domain-Driven Design підкреслює, що Bounded Context — ключ до управління складністю.
Порівняння DDD та CRUD
| Аспект | Традиційний CRUD | DDD |
|---|---|---|
| Цілісність даних | Розмазана по сервісах | Агрегат гарантує інваріанти |
| Швидкість змін | Ручний пошук усіх місць | Зміна тільки в одному агрегаті |
| Складність тестування | Багато моків | Ізольовані доменні тести |
| Час на нову фічу | Від 1 дня до тижня | Від 2 годин до 2 днів (у 2–3 рази швидше) |
Процес впровадження DDD
- Виділіть найскладніший bounded context (наприклад, управління замовленнями).
- Визначте ubiquitous language з бізнес-експертами — запишіть усі терміни.
- Спроєктуйте агрегати та value objects, зафіксуйте інваріанти.
- Напишіть модульні тести для доменної логіки (покриття 90%+).
- Інтегруйте контексти через доменні події та антикорупційний шар.
Кейс: інтернет-магазин електроніки
У проєкті з продажу електроніки ми виділили контекст 'Управління замовленнями'. Складність була в гнучких правилах знижок та акцій. Ми спроєктували агрегат Order з інваріантами (не можна додати товар після відправки). Модульні тести покрили 95% сценаріїв. Час розробки нових акцій скоротився з тижня до одного дня.Чому варто впроваджувати DDD поступово?
DDD вимагає дисципліни. Не варто покривати всю систему одразу. Почніть з найскладнішого контексту — це дасть швидкий результат і 60% зниження помилок у цій області. Поступово кількість багів зменшується на 40–50% порівняно з CRUD-підходом. Ми рекомендуємо виділити 2–3 ключові агрегати та побудувати навколо них решту архітектури. Наші клієнти економлять до 40% бюджету на підтримку після впровадження DDD.
Які проблеми вирішує DDD?
Основна проблема — розмита бізнес-логіка, коли зміни зачіпають безліч сервісів. DDD вводить чіткі межі контекстів та інкапсулює правила в агрегатах. Це знижує вартість змін на 30–50% і прискорює виведення нових фіч.
Будівельні блоки
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()));
}
}
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);
}
}
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>;
}
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;
}
}
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 };
}
}
Терміни та вартість
| Етап | Тривалість |
|---|---|
| Аналіз домену та Context Map | 1–2 тижні |
| Проєктування агрегатів | 1–2 тижні |
| Реалізація (3–5 агрегатів) | 3–5 тижнів |
| Інтеграція та тестування | 2–4 тижні |
| Разом | від 2 до 4 місяців |
Вартість розраховується індивідуально і залежить від складності домену. В середньому впровадження DDD окупається за 6–12 місяців завдяки зниженню вартості підтримки та прискоренню розробки нових фіч.
Що ви отримуєте в результаті
| Результат | Опис |
|---|---|
| Документація домену | Опис ubiquitous language, bounded context map |
| Реалізовані агрегати | Готовий код з бізнес-правилами |
| Модульні тести | Покриття 90%+ |
| Навчання команди | Воркшоп з DDD та роботи з кодом |
| Пост-релізна підтримка | 2 тижні після впровадження |
Типові помилки при впровадженні DDD
- Спроба покрити всю систему одразу — починайте з найскладнішого контексту.
- Нехтування Ubiquitous Language — без спільної мови команда буде плутатися в термінах.
- Ігнорування тестування — без модульних тестів втрачається сенс ізоляції логіки.
Замовте консультацію для оцінки вашого проєкту — ми підберемо оптимальний обсяг впровадження та сплануємо дорожню карту. Зв'яжіться з нами для безкоштовного аудиту поточної архітектури. Наші інженери гарантують якість та дотримання термінів.







