..Так получилось, что меня зацепила мысль об использовании DDD. Более того, я даже проинвестировал львиную долю своего и чужого времени в эту тему.
Но что-то идет не так. Написав с десяток репозиториев, сотню сущностей - ощущения ясности не приходит, каменный цветок не выходит.
Одна из причин - перерабатывать творчество Эванса и Вернона сложно, курсы Хорикова на pluralsight хоть и классные, но как-то про .NET... Вообще, создается впечатление что в СССР секса нет что за пределами .NET и Java - DDD не возможен.
И разные awesome листы как раз это подтверждают. Создается стойкое впечатление, что идея классная, книжки крутые по теме написаны, а взять и использовать - невозможно!
Давайте внезапно возьмем статью про Стратегический DDD и помедитируем на нее.
Слово домен, само по себе переводится очень плохо и русскому уху скорее напоминает про интернет серфинг. К коему неминуемо хочется прибегнуть, каждый раз встречая это слово. Переводить его как предмет - лишний раз вспоминать про школу. Предметная область - отличное словосочетание для того, чтобы вставить его в заголовок скачанного из интернета реферата. Есть понятное слово - тема.
Как моделировать тему?
Если перевести domain еще можно, то subdomain - совсем жесть.
Для начала тему надо подробить на БДСМ-практики. Целиком ее объять невозможно. Только Йода постиг тему полностью и теперь жмет это знание и передает его ученикам по частям.
Понять, что у нас практикует клиент - сложно. Он обычно говорит на своем языке. Но все же, выделить из речи одно - два предложения и перевести с непонятного на технический - можно. А еще в ходе бесед с клиентом нужно найти общий язык. Язык в ходе беседы будет расширяться, в него будут добавляться новые понятия. Из него будут выкидываться уже устоявшиеся и пустившие корни в виде адского-хард-кода термины.
В любом случае, прежде чем моделировать ту или иную практику - потребуется понять, какого она типа будет. Типов у практик может быть несколько:
- Основная
- Поддерживающая
- Общая
На примере сервиса для сдачи анализов можно определить практики как следующие:
Исходник PlantUML
@startuml
("основное" \n Записи Пациента) as P
("поддержка" \n Расписание) as S
("поддержка" \n Лаборатория) as L
("общее" \n Авторизация Пользователей) as A
("общее" \n Загрузка Файлов) as F
P -u-> F
P -d-> S
P -d-> L
L -> F
P -d-> A
L -d-> A
S -d-> A
@enduml
Основная - практика с которой организация получает основной доход. Пациент к нам приходит и сдает анализы - ему важен результат.
Поддерживающая - необходима для осуществления основной. Нам материал, который пациент сдал - нужно прогнать через лабораторию и получить этот самый результат. А еще прикольно избегать очередей - для этого потребуется вести журнал усилиями бабушки-вахтерши создать сервис для планирования временных слотов.
Общая - то что делают все. Загрузка файлов, сервис с пользователями и прочее.
Отделяем мух от котлет - от проблем к решениям
Знание темы и практик в лучшем случае даст нам понимание проблем бизнеса. Но при этом для решения этих проблем - нужно общаться дальше.
Решения проблем нам дадут эксперты - но прежде необходимо выработать общий с ними язык. С этого момента, все технические решения должны легко переводиться на язык экспертов.
Естественно в организации существуют разные эксперты с разными знаниями. Эти знания нужно ограничить. Ограничение знаний эксперта и перевод их с экспертного языка на общий - как раз и дает нам ограниченный контекст.
Теперь немного совы. Контексты между собой взаимодействуют. Поэтому хорошо создать карту их взаимодействия. Примеры - тут. Главное - не надо кидаться эту карту рисовать. Создаем сначала контексты, потом их объединяем, когда их станет больше трех :)
Промежуточный итог
Моделировать тему важно. Это позволит:
- Найти ограничения в контекстах.
- Избежать построения супер-моделей, которые превращают любую систему в кошмар.
- Понять, что мы
спасаем миррешаем проблемы конкретной организации, а не просто пишем код.
Тактика
Мы берем и непринужденно заходим в медитацию над следующей статьей.. Река течет, вода льется - давайте ловить рыбу
С экспертами нужно общаться и от общения с ними неизбежно будут появляться артефакты.
Какие артефакты можно поймать от эксперта?
Ну во-первых эксперт вряд ли начнет говорить о своей проблеме с объектов-значений. Скорее всего это будут общие фразы из серии:
- Мне тут нужно все назначения пациентов посмотреть и предложить им аналогичные препараты. Чтоб у них был выбор..
Данная фраза звучит уже внутри темы "Аптека", задав контекст "Назначение препаратов". При этом "Назначение" - это главная фишка, которая интересует эксперта. Ее в DDD называют "Корень Агрегата" - абракадабра, над которой нужно медитировать пару лет, если честно. И хоть и появляются статьи, которые делают эту штуку понятнее - но по классическим трудам эту штука не очень интуитивна.
Когда встречаются эти мысли про агрегаты, сущность, объект-значение и прочее - хочется выть. Можно проработать с десяток лет в индустрии и писать всю карьеру обычные модели, да заворачивать их в CRUD. Осознание новых понятий приходит не сразу. Но зато потом - "Я не понимал, не понимал, и вдруг как понял!"..
Итак, давайте сразу попробуем записать агрегат. Да еще и не на Java, а на Python. Зацепив pydantic.
from pydantic import BaseModel
PatientId = NewType('PatientId', int)
PrescriptionId = NewType('PrescriptionId', int)
class Patient(BaseModel):
"""
Данные о пациенте:
- имя
- фамилия
- телефон для связи
"""
patient_id: PatientId
first_name: str
last_name: str
phone: str
class ActiveComponent(BaseModel):
"""
Действующее вещество:
- Название
- Единицы измерения
- Количество
"""
name: str
unit: str
amount: float
class Medicine(BaseModel):
"""
Коммерческий препарат.
- Название
- Действующее вещество
"""
name: str
active_component: ActiveComponent
class Prescription(BaseModel):
"""
Назначение
- Пациент
- Список препаратов
"""
prescription_id: PrescriptionId
assign_date: Date
patient: Patient
medicine: List[Medicine]
Является ли "Назначение" агрегатом? И да, и нет. Обратите внимание, в рамках моделирования - нам не обязательно прикреплять id ко всем сущностям. Достаточно только к ключевым, а ими тут являются "Назначение" и "Пациент". Сами препараты - мы должны жестко зафиксировать в "Назначении".
Назначение пока что представляет собой анемичную модель. Видно, что без внешнего воздействия - ничего с ним не сделать.
Давайте его оживим:
class Prescription(BaseModel):
"""
Назначение
- Пациент
- Список препаратов
"""
prescription_id: PrescriptionId
assign_date: Date
patient_id: PatientId
medicines: List[Medicine]
def add_medicine(self, medicine):
self.medicines.append(medicine)
def get_patient(self, patient_repository: IPatientRepository):
return patient_repository.get(self.patient_id)
def get_alternative_medicines(self, medicine_repository: IMedicineRepository):
for medicine in self.medicines:
yield medicine_repository.get_by_active_component_name(medicine.active_component.name)
Стало поживее, одновременно передача репозиториев внутрь агрегата - спорная тема. А получение списка альтернативных препаратов - выглядит совсем кошмарно. Но это лишь для того, чтобы подумать о трилемме DDD.
Что на самом деле получаем?
- Агрегат можно обсуждать с экспертом, его код деревянный.
- Агрегат закрывает бизнес-логику. Ее также можно закрыть сервисом, но на практике мы используем сервисы чаще, чем это требуется. Чаще всего причина создания сервиса - криво сделанный агрегат.
Остаются открытыми вопросы:
- А пациент точно должен фигурировать в назначении? Он не из другого контекста?
- А если мы склеим пациентов с назначениями через сервис - это DDD или уже нет?
И я их не закрою :)
Полезные правила при дизайне агрегатов:
- Агрегаты должны быть небольшими. Это объект, который можно выгрузить в память целиком и система при этом не пострадает.
- К другим агрегатам и сущностям из агрегата нужно ссылаться через Id.
- В рамках одной транзакции в БД - желательно изменять только один агрегат.
- Оптимизм. Сохранение агрегата не должно ломать консистентность данных. Условно говоря, агрегат самодостаточен и если в нем произвели изменения - они произведены по правилам.
События в Теме
Все помнят анекдот - "Чем круче джип - тем дальше идти за трактором". Не многие скорбят о тех, кто трактор не нашел.
С агрегатами все хорошо, до тех пор пока не требуется взаимодействие между ними и их не становится 100500.
Как только пациенту становится интересно о том, что в назначение добавлен новый препарат - карточный домик рушится.
Давайте попробуем напрямую сказать пациенту о том, что новые супер-таблетки появились в назначении:
class Prescription(BaseModel):
"""
Назначение
- Пациент
- Список препаратов
"""
prescription_id: PrescriptionId
assign_date: Date
patient_id: PatientId
medicines: List[Medicine]
def add_medicine(self, medicine, patient_repository):
self.medicines.append(medicine)
patient = self.get_patient(patient_repository)
patient.notify(medicine)
def get_patient(self, patient_repository: IPatientRepository):
return patient_repository.get(self.patient_id)
Метод add_medicine стал выглядеть совсем больно, не так ли? Давайте уберем боль:
class PrescriptionEventType(enum.Enum):
MEDICINE_ADDED = 1
class PrescrtionEvent(BaseModel):
type: PrescriptionEventType
data: dict
class Prescription(BaseModel):
"""
Назначение
- Пациент
- Список препаратов
"""
prescription_id: PrescriptionId
assign_date: Date
patient_id: PatientId
medicines: List[Medicine]
log: List[PrescrtionEvent]
def add_medicine(self, medicine):
self.medicines.append(medicine)
self.log.append(PrescrtionEvent(
type=PrescriptionEventType.MEDICINE_ADDED,
data={
'patient': self.patient_id,
'medicine': medicine.dict(),
}
))
Итак, события вынесены в лог. А кто его будет читать? По хорошему - любая штука, которая использует агрегат. В python есть множество имплементаций в эту сторону, от развесистого event sourcing до плотно завязанных на иные паттерны решений. В книге cosmic python - целая половина книги про события.
Интуитивно подскажу следующее:
- Посмотрите как события реализованы в .NET и возможно это поможет.
- В 2021м году - искать готовые удобные библиотеки в python для DDD не надо. Их нет.
- Решение вида:
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
def handle_prescription_event(prescription_event: PrescriptionEvent):
if prescription_event.type == PrescriptionEventType.MEDICINE_ADDED:
...
@app.post("/add_medicine/{prescription_id}")
async def add_medicine(medicine: Medicine, prescription_id: PrescriptionId, background_tasks: BackgroundTasks):
prescription = prescription_service.get_prescription(prescription_id)
prescription.add_medicine(medicine)
prescription_service.update(prescription)
for e in prescription.log:
background_tasks.add_task(handle_prescription_event, e)
return {'medicine_added': 1}
вполне себе работает.
- Используя свойство оптимистичности агрегатов - можно немного расслабиться по поводу проблем с конкурентностью событий.
Я не уверен, на каком именно параграфе данной статьи убежал эксперт по теме. Но давайте заглянем дальше, туда, куда ему точно смотреть не стоит.
Итак, есть вещи которые при беседе с экспертом можно упоминать, но в детали лучше не вдаваться, а именно:
- Репозитории. Это слово можно использовать вместо базы данных. А можно использовать "база данных" вместо "репозиторий" - и это будет продуктивнее. Еще можно попробовать слово "хранилище". Цель этого понятия простая - мы достаем агрегат в помощью хранилища и после работы с ним - кладем его обратно в хранилище.
Выглядит репозиторий примитивно:
class IPatientRepository(object):
def get_patient_by_phone(self, phone: str):
raise NotADirectoryError()
def get_patients(self):
raise NotADirectoryError()
class PatientRepository(IPatientRepository):
def __init__(self):
self.db = {}
def get_patient_by_phone(self, phone: str):
return self.db.get('patients_by_phone', {}).get(phone)
def get_patients(self):
for patient in self.db['patients'].values():
yield patient
Суть в том, что вместо вызовов ORM или прямых обращений в БД - мы работаем с ней через репозиторий. Тестируемость кода при таком подходе растет. Репозиторий - один из наиболее часто применяемых дизайн паттернов.
Сервисы. Конструкция которая содержит в себе всю логику, которая не вошла в агрегат. Может использовать несколько репозиториев, вызывать сторонние системы и т.д.
Фабрика позволяет создавать агрегаты принимая на вход простые инструкции. Эдакий синтаксический сахар для агрегатов.
Модули. В случае java, python и go - это packages. В случае с .NET - namespaces. Суть также простая - не надо класть все репозитории, сущности и т.д. в один модуль.
Очень часто DDD идет бок о бок с гексагональной архитектурой. Об этом есть хорошие доклады. Я же избегу воспеваний гексагональной архитектуры - уверен, что находясь в этой точке мы уже достаточно запутаны и упоминание о том, что DDD позволяет использовать 10 фреймворков одновременно на одном тематическом ядре - нас запутает еще больше :)
P.S.: Я пишу эту статью в специально сумбурном стиле, чтобы приблизительно показать архитекторам душ, какая ерунда творится в голове среднего разработчика от прочитанного о DDD. До сих пор не существует мягкого входа в тему, в то время как разработка приложений в традиционном стиле ведет к лапше разного качества.
Спасибо.
Top comments (0)