DEV Community

loading...

Где же ты, DDD?

tigrus profile image Nikolay Fominykh ・2 min read

..Так получилось, что меня зацепила мысль об использовании 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
Enter fullscreen mode Exit fullscreen mode

Основная - практика с которой организация получает основной доход. Пациент к нам приходит и сдает анализы - ему важен результат.

Поддерживающая - необходима для осуществления основной. Нам материал, который пациент сдал - нужно прогнать через лабораторию и получить этот самый результат. А еще прикольно избегать очередей - для этого потребуется вести журнал усилиями бабушки-вахтерши создать сервис для планирования временных слотов.

Общая - то что делают все. Загрузка файлов, сервис с пользователями и прочее.

Отделяем мух от котлет - от проблем к решениям

Знание темы и практик в лучшем случае даст нам понимание проблем бизнеса. Но при этом для решения этих проблем - нужно общаться дальше.

Решения проблем нам дадут эксперты - но прежде необходимо выработать общий с ними язык. С этого момента, все технические решения должны легко переводиться на язык экспертов.

Естественно в организации существуют разные эксперты с разными знаниями. Эти знания нужно ограничить. Ограничение знаний эксперта и перевод их с экспертного языка на общий - как раз и дает нам ограниченный контекст.

Теперь немного совы. Контексты между собой взаимодействуют. Поэтому хорошо создать карту их взаимодействия. Примеры - тут. Главное - не надо кидаться эту карту рисовать. Создаем сначала контексты, потом их объединяем, когда их станет больше трех :)

Промежуточный итог

Моделировать тему важно. Это позволит:

  1. Найти ограничения в контекстах.
  2. Избежать построения супер-моделей, которые превращают любую систему в кошмар.
  3. Понять, что мы спасаем мир решаем проблемы конкретной организации, а не просто пишем код.

Тактика

Мы берем и непринужденно заходим в медитацию над следующей статьей.. Река течет, вода льется - давайте ловить рыбу

С экспертами нужно общаться и от общения с ними неизбежно будут появляться артефакты.

Какие артефакты можно поймать от эксперта?

Ну во-первых эксперт вряд ли начнет говорить о своей проблеме с объектов-значений. Скорее всего это будут общие фразы из серии:

  • Мне тут нужно все назначения пациентов посмотреть и предложить им аналогичные препараты. Чтоб у них был выбор..

Данная фраза звучит уже внутри темы "Аптека", задав контекст "Назначение препаратов". При этом "Назначение" - это главная фишка, которая интересует эксперта. Ее в 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]
Enter fullscreen mode Exit fullscreen mode

Является ли "Назначение" агрегатом? И да, и нет. Обратите внимание, в рамках моделирования - нам не обязательно прикреплять 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)
Enter fullscreen mode Exit fullscreen mode

Стало поживее, одновременно передача репозиториев внутрь агрегата - спорная тема. А получение списка альтернативных препаратов - выглядит совсем кошмарно. Но это лишь для того, чтобы подумать о трилемме DDD.

Что на самом деле получаем?

  • Агрегат можно обсуждать с экспертом, его код деревянный.
  • Агрегат закрывает бизнес-логику. Ее также можно закрыть сервисом, но на практике мы используем сервисы чаще, чем это требуется. Чаще всего причина создания сервиса - криво сделанный агрегат.

Остаются открытыми вопросы:

  • А пациент точно должен фигурировать в назначении? Он не из другого контекста?
  • А если мы склеим пациентов с назначениями через сервис - это DDD или уже нет?

И я их не закрою :)

Полезные правила при дизайне агрегатов:

  1. Агрегаты должны быть небольшими. Это объект, который можно выгрузить в память целиком и система при этом не пострадает.
  2. К другим агрегатам и сущностям из агрегата нужно ссылаться через Id.
  3. В рамках одной транзакции в БД - желательно изменять только один агрегат.
  4. Оптимизм. Сохранение агрегата не должно ломать консистентность данных. Условно говоря, агрегат самодостаточен и если в нем произвели изменения - они произведены по правилам.

События в Теме

Все помнят анекдот - "Чем круче джип - тем дальше идти за трактором". Не многие скорбят о тех, кто трактор не нашел.

С агрегатами все хорошо, до тех пор пока не требуется взаимодействие между ними и их не становится 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)
Enter fullscreen mode Exit fullscreen mode

Метод 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(),
            }
        ))
Enter fullscreen mode Exit fullscreen mode

Итак, события вынесены в лог. А кто его будет читать? По хорошему - любая штука, которая использует агрегат. В 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}
Enter fullscreen mode Exit fullscreen mode

вполне себе работает.

  • Используя свойство оптимистичности агрегатов - можно немного расслабиться по поводу проблем с конкурентностью событий.

Я не уверен, на каком именно параграфе данной статьи убежал эксперт по теме. Но давайте заглянем дальше, туда, куда ему точно смотреть не стоит.

Итак, есть вещи которые при беседе с экспертом можно упоминать, но в детали лучше не вдаваться, а именно:

  • Репозитории. Это слово можно использовать вместо базы данных. А можно использовать "база данных" вместо "репозиторий" - и это будет продуктивнее. Еще можно попробовать слово "хранилище". Цель этого понятия простая - мы достаем агрегат в помощью хранилища и после работы с ним - кладем его обратно в хранилище.

Выглядит репозиторий примитивно:

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
Enter fullscreen mode Exit fullscreen mode

Суть в том, что вместо вызовов ORM или прямых обращений в БД - мы работаем с ней через репозиторий. Тестируемость кода при таком подходе растет. Репозиторий - один из наиболее часто применяемых дизайн паттернов.

  • Сервисы. Конструкция которая содержит в себе всю логику, которая не вошла в агрегат. Может использовать несколько репозиториев, вызывать сторонние системы и т.д.

  • Фабрика позволяет создавать агрегаты принимая на вход простые инструкции. Эдакий синтаксический сахар для агрегатов.

  • Модули. В случае java, python и go - это packages. В случае с .NET - namespaces. Суть также простая - не надо класть все репозитории, сущности и т.д. в один модуль.

Очень часто DDD идет бок о бок с гексагональной архитектурой. Об этом есть хорошие доклады. Я же избегу воспеваний гексагональной архитектуры - уверен, что находясь в этой точке мы уже достаточно запутаны и упоминание о том, что DDD позволяет использовать 10 фреймворков одновременно на одном тематическом ядре - нас запутает еще больше :)

P.S.: Я пишу эту статью в специально сумбурном стиле, чтобы приблизительно показать архитекторам душ, какая ерунда творится в голове среднего разработчика от прочитанного о DDD. До сих пор не существует мягкого входа в тему, в то время как разработка приложений в традиционном стиле ведет к лапше разного качества.

Спасибо.

Discussion (0)

Forem Open with the Forem app