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), потім тактика всередині кожного контексту.
Послідовність кроків:
- Виділити домен - предметну область системи в цілому.
- Поділити на субдомени - визначити core (де лежить конкурентна перевага), supporting (потрібне, але не унікальне) і generic (готові рішення на ринку).
- Сформувати Ubiquitous Language - разом з бізнес-експертами зафіксувати терміни для кожного піддомену.
- Виділити Bounded Contexts - провести межі, в яких мова та модель залишаються однозначними; описати відносини між ними (Context Map).
- Застосувати тактичні патерни - усередині кожного 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) - кожен описує іншу політику відносин між командами та контекстами.