Skip to content

DDD

DDD

Основні принципи DDD

Domain-Driven Design (DDD) — це підхід до проєктування складних програмних систем, спрямований на чітке вираження бізнес-логіки та її ізоляцію від технічних деталей. Головна ідея DDD полягає в тому, щоб модель програми максимально відповідала реальним бізнес-процесам і була зрозумілою як для розробників, так і для експертів предметної області.

DDD складається з двох основних рівнів:

Стратегічний рівень

  • Bounded Contexts — визначає межі системи та контексти, в яких певні терміни мають специфічне значення
  • Ubiquitous Language — формує єдину мову для ефективної взаємодії з бізнес-експертами та між командами
  • Context Mapping — керує відносинами між різними контекстами та їх інтеграцією

Тактичний рівень - описує конкретні шаблони реалізації всередині bounded context

  • Entities — сутності з унікальною ідентичністю
  • Aggregates — кластери пов'язаних об'єктів
  • Value Objects — об'єкти без ідентичності
  • Domain Services — сервіси для бізнес-логіки, що не належить конкретній сутності

Уся бізнес-логіка зосереджена в доменному шарі, а саме в сутностях, агрегатах та Value Objects. Тут визначаються:

  • Бізнес-інваріанти (правила, що завжди мають виконуватися)
  • Допустимі переходи станів
  • Валідні операції

Зміна стану та перевірка інваріантів відбуваються виключно через методи сутності, які явно виражають бізнес-зміст: complete(), cancel(), change_owner() тощо.

Доменні моделі не залежать від інфраструктури (ані від баз даних, ані від транспорту, ані від зовнішніх DTO), і це дозволяє повторно використовувати бізнес-логіку незалежно від того, які технології використовуються в інфраструктурному шарі. Репозиторії, адаптери, транспорт, логування, моніторинг та інша інфраструктура виносяться за межі домену й використовуються тільки в application-шарі.

Сервіси-оркестратори - це тонкий шар, який координує дії між доменними об'єктами та інфраструктурою. Він сам не містить бізнес-логіки, але керує викликами доменних методів, збором даних і збереженням результатів. Він також єдиний шар, який може спілкуватися з зовнішніми системами й взаємодіяти з репозиторіями, надсилати події, логувати тощо.

Переваги використання DDD:

  • Чітке вираження бізнес-логіки — код відображає реальні бізнес-процеси
  • Покращена комунікація — спільна мова з бізнес-експертами
  • Модульність — чіткі межі між компонентами
  • Тестованість — легко тестувати бізнес-логіку ізольовано
  • Еволюційність — система легко адаптується до змін у бізнесі
  • Повторне використання — доменна логіка не залежить від технологій

Домен

Доменна модель — це сукупність структур, які виражають бізнес-правила та поведінку предметної області.

У широкому сенсі це не один конкретний тип, а сукупність:

  • Entity
  • Aggregate
  • Value Object (VO)
  • Domain Service.

Entity (сутність) - об'єкт предметної області з ідентичністю (ID) і життєвим циклом. Він може мати стан, бізнес-методи й брати участь в агрегатах. Декілька об'єктів з однаковими атрибутами, але різними ідентифікаторами, є різними сутностями. Навпаки, об'єкти з різними атрибутами, але однаковим ідентифікатором, розглядаються як різні стани однієї сутності.

Ознаки:

  • Має ID, за яким визначається унікальність.
  • Може мати змінюваний стан.
  • Інкапсулює бізнес-інваріанти.
  • Може бути частиною агрегату або його коренем (Aggregate Root).

Сутність і її інваріанти

# domain/tasks/entity.py
class Task:
    def __init__(self, id: str, status: str, owner_id: str):
        self.id = id
        self.status = status
        self.owner_id = owner_id

    def complete(self):
        if self.status != "in_progress":
            raise ValueError("Invalid status transition")
        return Task(self.id, "completed", self.owner_id)

    def assign_to(self, new_owner_id: str): 
        if self.status == "completed": 
            raise ValueError("Unable to change the owner of a completed task") 
        return Task(self.id, self.status, new_owner_id)

Value Object VO(Об'єкт-значення) - це об'єкт, який не має ідентичності й визначається лише набором своїх атрибутів. Він іммутабельний, тобто після створення його стан не змінюється, а всі зміни вносяться через створення нового екземпляра. Використовується для представлення концепцій, не будучи сутністю. Дозволяють повторно використовувати бізнес-логіку та не дублювати валідації. Два об’єкти-значення з однаковими атрибутами вважаються еквівалентними. Такий об’єкт є іммутабельним.

Ознаки:

  • Немає ID. Об'єкти вважаються рівними, якщо у них однакові значення.
  • Іммутабельність. Після створення не змінюється.
  • Інкапсулює валідацію. Перевіряє коректність значень на етапі створення.
  • Не зберігається окремо. Не має власних таблиць/репозиторіїв.
# domain/value_objects.py
class Email:
    def __init__(self, value: str):
        if not self._is_valid_email(value):
            raise ValueError("Invalid email")
        self.value = value

    def _is_valid_email(self, email: str) -> bool:
        return "@" in email and "." in email.split("@")[1]

    def __eq__(self, other):
        return isinstance(other, Email) and self.value == other.value

    def __str__(self):
        return self.value

Aggregate (Агрегат) - це група сутностей і об'єктів-значень, які логічно пов'язані та змінюються як єдине ціле. Агрегат визначає межі консистентності й вхідну точку для операцій над пов'язаними об'єктами. Має кореневу сутність (Aggregate Root), через методи якої здійснюється доступ і модифікація даних.

Ознаки:

  • Має Aggregate Root - головну сутність для доступу до агрегату. Тільки через неї здійснюється доступ до інших даних.
  • Гарантія інваріантів. Будь-яка операція зберігає внутрішню узгодженість.
  • Визначає межі транзакції. Все всередині агрегату змінюється в рамках однієї транзакції.
  • Не розкриває вкладені сутності назовні напряму. Зовнішній доступ тільки через Aggregate Root.
# domain/orders/aggregate.py
class Order:  # Aggregate Root
    def __init__(self, id: str, customer_id: str):
        self.id = id
        self.customer_id = customer_id
        self.items = []
        self.status = "draft"

    def add_item(self, product_id: str, quantity: int, price: Money):
        if self.status != "draft":
            raise ValueError("Unable to add product to inactive order")
        item = OrderItem(product_id, quantity, price)
        self.items.append(item)

    def confirm(self):
        if not self.items:
            raise ValueError("Unable to confirm empty order")
        self.status = "confirmed"

Domain Service - це компонент доменного шару, який інкапсулює бізнес-логіку, що не належить конкретній сутності або агрегату, але все ще є частиною предметної області.

Використовується, коли логіка:

  • неприродно лягає на одну конкретну сутність
  • потребує координації кількох об'єктів
  • при цьому залишається всередині одного домену.

Ознаки:

  • Містить бізнес-логіку
  • Не має власного стану (stateless)
  • Не є сутністю чи Value Object
  • Працює з кількома доменними моделями
  • Не залежить від інфраструктури
# domain/services/pricing_service.py
class PricingService:
    def calculate_discount(self, customer: Customer, order: Order) -> Money:
        discount_rate = 0.0

        if customer.is_vip():
            discount_rate += 0.1

        if order.total_amount() > Money(1000, "USD"):
            discount_rate += 0.05

        return order.total_amount() * discount_rate

Application Service

# application/task_service.py
class TaskService:
    def __init__(self, task_repo: TaskRepository, event_publisher: EventPublisher):
        self.task_repo = task_repo
        self.event_publisher = event_publisher

    def complete_task(self, task_id: str, user_id: str):
        task = self.task_repo.get_by_id(task_id)  # Get data

        if task.owner_id != user_id:  # Check access rights
            raise ValueError("Insufficient rights to complete task")

        completed_task = task.complete()  # Call domain logic

        self.task_repo.save(completed_task)  # Save the result

        self.event_publisher.publish(TaskCompletedEvent(completed_task.id))  # Publish the event

Domain Service vs Application Service

  • Domain Service - операція стосується однієї сутності або агрегату.
  • Application Service - потрібна координація кількох доменів або інфраструктурних компонентів.

Допоміжні елементи DDD

View/Read Model - це проекція доменної моделі, призначена тільки для читання, часто агрегована під конкретний use-case. Вона оптимізує читання даних, розвантажує доменну модель і дозволяє безпечно відображати агреговану інформацію.

Ознаки:

  • Не є частиною домену.
  • Не містить бізнес-логіки. Якщо у View з'являється поведінка, то розмивається межа між Domain Model і View Model, що порушує SRP. Виняток: методи, які не змінюють стан і не виконують бізнес-логіку, наприклад __str__(), format().
  • Використовується для відображення, звітів, API-відповідей тощо.
  • Може містити агреговані дані з кількох джерел.
# views/task_view.py
class TaskSummaryView:
    def __init__(self, id: str, title: str, status: str, owner_name: str, created_at: datetime):
        self.id = id
        self.title = title
        self.status = status
        self.owner_name = owner_name
        self.created_at = created_at

    def format_for_display(self) -> str:
        return f"{self.title} ({self.status}) - {self.owner_name}"

Data Transfer Object (DTO) - тимчасова структура, що використовується для передачі даних між шарами, наприклад, між транспортом і сервісом. DTO забезпечують слабкий зв'язок між шарами й сервісами, дозволяють ізолювати зміни й формувати API-контракти.

Ознаки:

  • Не має поведінки й бізнес-логіки.
  • Використовується в транспортному шарі (gRPC, HTTP), мапиться до/з Entity/VO через конвертери. Ізолює зовнішній API від внутрішніх моделей.
  • Може бути двостороннім (RequestDTO ↔ ResponseDTO).
# dto/task_dto.py
class CreateTaskRequest:
    def __init__(self, title: str, description: str, owner_id: str):
        self.title = title
        self.description = description
        self.owner_id = owner_id

class TaskResponse:
    def __init__(self, id: str, title: str, status: str, owner_id: str):
        self.id = id
        self.title = title
        self.status = status
        self.owner_id = owner_id

У чому різниця між стратегічним і тактичним рівнями DDD?

Summary

Стратегічний рівень - про межі та мову (domain, subdomain, Bounded Context, Ubiquitous Language); тактичний - про реалізацію всередині цих меж (Aggregate, Entity, VO, Repository, Factory, Domain Event). Основна цінність DDD - саме стратегічний рівень.

Стратегічний рівень працює зі складністю на рівні бізнесу та архітектури:

  • Domain / Subdomain - виділення предметної області та її поділ на core/supporting/generic піддомени.
  • Ubiquitous Language - єдина мова між розробниками та бізнес-експертами, спільна для коду, документації та розмов.
  • Bounded Context - явна межа, всередині якої терміни Ubiquitous Language мають однозначне значення.
  • Context Mapping - опис відносин між контекстами (Customer/Supplier, Conformist, Anti-Corruption Layer тощо).

Тактичний рівень - це низькорівневі ООП-патерни всередині одного Bounded Context:

  • Aggregate, Aggregate Root
  • Entity
  • Value Object
  • Repository
  • Factory
  • Domain Service / Domain Event

Найбільший ефект в управлінні складністю проєкту дає саме стратегічний рівень. Тактичні патерни - це інструмент реалізації, а не самоціль.

Що таке Light DDD і чому це анти-патерн?

Summary

Light DDD - застосування лише тактичних патернів (Aggregate, Entity, VO, Repository, Factory) без стратегічного аналізу. Код виглядає як DDD, але не вирішує головної задачі - керування складністю та узгодження з бізнесом.

Типовий сценарій: розробник прочитав Еванса, побачив перелік патернів і почав писати ООП-код, у якому є агрегати, value objects, фабрики, репозиторії - усе як у книжці. Код працює, його можна запустити в проді. Але це не DDD, а Light DDD - карго-культ.

Чому це проблема:

  • Немає виділених доменів і піддоменів - незрозуміло, де core-логіка, а де generic.
  • Немає Bounded Context - терміни розпливаються по всій системі, той самий Order означає різне в різних місцях.
  • Немає Ubiquitous Language - розробники говорять однією мовою, бізнес - іншою; модель не відображає реальні процеси.
  • Тактичні патерни прикладаються до випадково нарізаних модулів, межі агрегатів не узгоджені з бізнес-інваріантами.

Результат: формально "правильні" класи, але та сама стара заплутаність, заради боротьби з якою DDD і вигадували.

Який правильний порядок застосування DDD?

Summary

Спочатку стратегія (домени → субдомени → Ubiquitous Language → Bounded Contexts), потім тактика всередині кожного контексту.

Послідовність кроків:

  1. Виділити домен - предметну область системи в цілому.
  2. Поділити на субдомени - визначити core (де лежить конкурентна перевага), supporting (потрібне, але не унікальне) і generic (готові рішення на ринку).
  3. Сформувати Ubiquitous Language - разом з бізнес-експертами зафіксувати терміни для кожного піддомену.
  4. Виділити Bounded Contexts - провести межі, в яких мова та модель залишаються однозначними; описати відносини між ними (Context Map).
  5. Застосувати тактичні патерни - усередині кожного Bounded Context спроєктувати Aggregates, Entities, Value Objects, Repositories, Factories, Domain Services, Domain Events.

Якщо пропустити кроки 1-4 і одразу стрибнути в тактику, отримаємо Light DDD. Якщо зробити лише 1-4 без тактики - отримаємо хорошу архітектурну карту без робочої реалізації. Цінність дає саме поєднання в правильному порядку.

Що таке Bounded Context і як він пов'язаний з Ubiquitous Language?

Summary

Bounded Context - це зона узгодженості Ubiquitous Language: межа, всередині якої кожен термін має одне чітке значення для бізнесу й коду.

Ubiquitous Language (єдина мова) - словник термінів предметної області, якими однаково оперують і бізнес, і розробники. Бізнес висловлює концепції певними мовними конструкціями, а розробники розуміють той самий термін так само й переносять його в код один-в-один.

Для Еванса Ubiquitous Language і Bounded Context - дві нерозривні опори DDD: модель не існує поза контекстом, а контекст визначається саме мовою, узгодженою в його межах. Тактичні конструкції (агрегати, сутності, value objects) реалізують цю мову й похідні від неї.

Конфлікт термінів при масштабуванні. Коли в проєкті з'являється багато стейкхолдерів, ті самі слова починають означати різні речі. "Клієнт" у відділі продажів - це лід або потенційний покупець з контактами; "Клієнт" у білінгу - платник із договором і балансом; "Клієнт" у підтримці - користувач з тікетами. Назва одна, сутність - різна.

Якщо неможливо вигадати окремий унікальний термін, який задовольнить усіх, виділяють Bounded Context - окрему зону, в якій термін "Клієнт" означає щось одне й конкретне. У сусідньому контексті це буде інша сутність зі своїм набором атрибутів і поведінки, навіть якщо називається так само.

Це дозволяє не створювати "божественну" сутність з мільярдом атрибутів на всі випадки життя, а тримати кожну модель сфокусованою на своєму контексті.

Природа межі: мова, не команда

У самій назві ключове слово - обмежений (bounded). Як мінімум це межа узгодженості Ubiquitous Language: усередині контексту кожен термін має одне значення. Це необхідна умова - якщо мова в одному місці ламається, контекст обов'язково треба виділити. Як максимум - це межа коду, команд, релізного циклу та бази даних; часто контексти збігаються з мікросервісами та з командами, які їх розробляють.

За Евансом мовний конфлікт - мінімальний критерій поділу. Але ділити можна й далі, навіть без мовних конфліктів, - за іншими ознаками:

  • Розмір концепції: маркетинг і продажі можуть не перетинатися термінами, але бути настільки великими, що зручніше винести їх окремо.
  • Організаційна структура: над різними частинами працюють різні команди, які хочуть незалежний реліз і власну кодову базу.
  • Масштабованість: різний профіль навантаження або різні SLA.

У коді: одна назва, дві моделі

Один і той самий "Customer" - дві різні моделі в різних контекстах, з різними полями та поведінкою; між ними - явне мапування за ID.

# contexts/sales/customer.py - Sales Bounded Context
class Customer:
    def __init__(self, id: str, lead_source: str, contact: Email):
        self.id = id
        self.lead_source = lead_source   # marketing-specific attribute
        self.contact = contact

    def qualify_as_lead(self) -> bool: ...
# contexts/billing/customer.py - Billing Bounded Context
class Customer:
    def __init__(self, id: str, tax_id: str, balance: Money, contract_id: str):
        self.id = id
        self.tax_id = tax_id             # billing-specific attribute
        self.balance = balance
        self.contract_id = contract_id

    def charge(self, amount: Money) -> None: ...

Назва класу однакова, але це різні сутності в різних модулях/сервісах. Зв'язок між ними - через спільний customer_id і явний контракт інтеграції (Context Map: events, anti-corruption layer, shared kernel тощо), а не через спільну "товсту" модель.

Виключення

Якщо мова всередині концепції узгоджена і одна команда тримає її повністю - окремий контекст не обов'язковий:

  • Терміни ніде не перетинаються з іншими значеннями - мова консистентна.
  • Над концепцією працює одна команда, поділ не дасть організаційного виграшу.
  • Концепція мала, винесення в окремий контекст додасть більше інфраструктурних витрат (мапування, інтеграція, окреме сховище), ніж дасть ясності.

У такому разі концепція спокійно живе разом з іншими в межах одного контексту - це не порушення DDD.

Links

Як інтегрувати різні bounded contexts?

Summary

Коли два мікросервіси живуть у різних bounded contexts, на межі потрібен переклад мови: ACL ставлять на стороні споживача, щоб захистити власну модель, OHS - на стороні постачальника, щоб опублікувати стабільний контракт для багатьох клієнтів.

Всередині одного bounded context сервіси розмовляють спільною Ubiquitous Language, тож DTO серіалізуються однаково. Коли сервіс A з одного контексту мусить надсилати дані сервісу B з іншого контексту, Customer у A і Customer у B - це різні концепції з різною семантикою. Прямий маппінг моделей псує домен: чужа мова "протікає" всередину й деформує власні інваріанти. Тому між контекстами потрібен transformation boundary - шар, який перекладає одну мову на іншу.

Вибір транспорту (HTTP, брокер, gRPC) вторинний відносно того, на чиєму боці стоїть шар перекладу й хто під кого підлаштовується - хоча сам по собі транспорт впливає на coupling (синхронний RPC vs асинхронні події дають різну семантику для споживачів).

Anti-Corruption Layer (ACL)

Summary

ACL - це шар-фасад на стороні споживача, який перекладає зовнішню модель у внутрішню, щоб чужа мова не "коррумпувала" власний домен.

Споживач не може (або не хоче) впливати на постачальника - наприклад, legacy-система, зовнішній вендор, інша команда зі своїм релізним циклом. Щоб не тягнути їхній словник у власний домен, споживач будує адаптер: приймає вхідний DTO/event у "чужому" форматі й конвертує його у власні Entity/VO. Якщо постачальник змінить контракт, ламається лише ACL, а доменна модель залишається стабільною.

# acl/billing_acl.py
# Upstream "Billing" service speaks its own language: invoices, line_items, gross_total.
# Our "Orders" context speaks: Order, OrderItem, Money.

class BillingACL:
    def to_order(self, payload: dict) -> Order:
        # Translate foreign vocabulary into our Ubiquitous Language
        order = Order(id=payload["invoice_id"], customer_id=payload["payer_ref"])
        for li in payload["line_items"]:
            order.add_item(
                product_id=li["sku"],
                quantity=li["qty"],
                price=Money(li["gross"], li["ccy"]),  # -> our VO
            )
        return order

Коли застосовувати:

  • Інтеграція з legacy чи зовнішнім API, який ми не контролюємо.
  • Upstream-команда диктує свій формат, а ми хочемо ізолювати свій домен від його змін.
  • Потрібно адаптувати кілька різних upstream-постачальників до однієї внутрішньої моделі.

Open Host Service (OHS) і Published Language

Summary

У Еванса це дві парні, але окремі речі: OHS - сам сервіс/протокол, який постачальник відкриває багатьом споживачам; Published Language - стабільна задокументована мова (схема, контракт), якою цей сервіс розмовляє. Зазвичай вони застосовуються разом: OHS публікує контракт у форматі Published Language.

Постачальник обслуговує багато клієнтів і не хоче зв'язуватися з кожним окремо. Він відкриває OHS - єдиний публічний сервіс/API/потік подій - і формалізує його контракт як Published Language: event schema, OpenAPI чи proto-визначення, що не повторює внутрішню модель один-в-один. Внутрішній Order перекладається в зовнішній OrderPublished з полями, придатними для зовнішнього світу. Усі споживачі говорять цією Published Language; зміна внутрішньої моделі постачальника не ламає клієнтів, доки контракт стабільний.

# ohs/order_published_language.py
# Internal aggregate stays rich; the published event is a flat, stable contract.

class OrderPublisher:
    def publish_confirmed(self, order: Order) -> None:
        event = {
            "event": "order.confirmed",
            "version": "1",
            "order_id": order.id,
            "customer_id": order.customer_id,
            "items": [
                {"sku": i.product_id, "qty": i.quantity, "amount": str(i.price)}
                for i in order.items
            ],
        }
        self.broker.publish("orders", event)  # -> Published Language

Коли застосовувати:

  • Один сервіс-постачальник з багатьма споживачами (типова шина подій).
  • Команда-постачальник хоче зафіксувати контракт і еволюціонувати його версіоновано.
  • Внутрішня модель надто багата чи нестабільна, щоб віддавати її "як є".

Як обрати: ACL чи OHS?

Summary

Питання не "який кращий", а "хто кого змушений підлаштовуватися": хто абсорбує зміни контракту, той і ставить у себе шар перекладу.

  • ACL - коли upstream "вище за рангом" і диктує мову, а downstream змушений адаптуватися. Перекладач живе у downstream.
  • OHS - коли постачальник свідомо стає платформою для багатьох клієнтів і бере на себе стабільність контракту. Перекладач живе в upstream, у вигляді published language.
  • На практиці патерни часто комбінуються: постачальник публікує OHS, а конкретний споживач додатково обгортає його своїм ACL, бо хоче ізолювати власний домен навіть від стабільного, але чужого словника.

Це два з кількох патернів Context Mapping за Evans (поряд із Shared Kernel, Customer/Supplier, Conformist, Partnership) - кожен описує іншу політику відносин між командами та контекстами.