Реалізація 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 — без спільної мови команда буде плутатися в термінах.
- Ігнорування тестування — без модульних тестів втрачається сенс ізоляції логіки.
Замовте консультацію для оцінки вашого проєкту — ми підберемо оптимальний обсяг впровадження та сплануємо дорожню карту. Зв'яжіться з нами для безкоштовного аудиту поточної архітектури. Наші інженери гарантують якість та дотримання термінів.







