DEV Community

Nikolay Fominykh
Nikolay Fominykh

Posted on

3 2

Где же ты, DDD?

..Так получилось, что меня зацепила мысль об использовании 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. До сих пор не существует мягкого входа в тему, в то время как разработка приложений в традиционном стиле ведет к лапше разного качества.

Спасибо.

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay