Pull to refresh

Comments 18

Немного покритикую что хорошо а что нет.

Доменная модель описывает бизнес-сущность и её правила.

Обратите внимание, домен ничего не знает про инфраструктуру, здесь нет ORM, SQL, pydantic, boto3, etc.

Раз, и между двумя предложениями рождается противотечение. Это фундаментальная проблема ООП подхода на бэке. Мы не можем оперировать всей доменной логикой без инфраструктуры. Как только появиться необходимость валидировать на существование других данных в БД или что еще хуже, менять их - этот подход сразу превратиться в тыкву.

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

Дальше:

# usecases/invoice/upload.py
class UploadInvoiceUseCase:
    def __init__(self, storage: IFileStorage, uow: IUoW):
        self.storage = storage
        self.uow = uow

    def execute(
        self,
        invoice_id: UUID,
        content: bytes,
    ) -> UploadInvoiceOutput:
        invoice = self.uow.invoice_gate.get_by_id(invoice_id)

        if invoice.is_cancelled:
            raise InvoiceCancelledError()

        key = f"invoices/{invoice_id}.pdf"

        self.storage.save(key, content)

        invoice.mark_uploaded()

        self.uow.invoice_gate.save(invoice)

        return UploadInvoiceOutput(
            invoice_id=invoice.id,
            uploaded=True,
        )

Окей. Зачем нам этот класс? Вы по сути написали очень странную и неуклюжую функцию. Просто передайте storage и uow в параметры и выкинете никому не нужный класс.

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

В-третих, что будете делать, когда появиться общая логика между usecases? Дублировать? Или выносить в еще одну костыль-абстракцию?

Сервис намного проще как решение. Ну или просто оставить это функциями.

И, возможно, именно в эпоху AI эта стоимость начинает постепенно снижаться. То, что раньше требовало большого количества рутинной работы, всё чаще генерируется, поддерживается и рефакторится значительно проще.

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

Раз, и между двумя предложениями рождается противотечение. Это фундаментальная проблема ООП подхода на бэке. Мы не можем оперировать всей доменной логикой без инфраструктуры. Как только появиться необходимость валидировать на существование других данных в БД или что еще хуже, менять их - этот подход сразу превратиться в тыкву.

Domain model в моём примере не пытается инкапсулировать вообще весь бизнес процесс системы. Её задача в локальных инвариантах сущности:

  • допустимых переходах состояний

  • защите собственного состояния

  • правилах, которые принадлежат именно этому объекту Entity не должна отвечать за то, что ей не принадлежит (Привет, первый pattern из GRASP)

Да, в статье сейчас написано это немного неудачно. Исправлю.

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

Не понял почему "не самодостаточна даже для соблюдения своих собственных инвариантов", можете подробнее раскрыть, пожалуйста.

Окей. Зачем нам этот класс? Вы по сути написали очень странную и неуклюжую функцию. Просто передайте storage и uow в параметры и выкинете никому не нужный класс

Это можно оформить и функцией, и классом - это уже вопрос стиля и архитектурных предпочтений. Например, я использую DI фреймворк и мне так более предпочтительнее.

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

Бизнес-сущность должна отвечать только за своё состояние и свои инварианты. Это её граница ответственности. Всё, что выходит за пределы управления одним объектом, а тем более задействует какие-то инфрастуктурные компоненты - выносим в юскейс.

В-третих, что будете делать, когда появиться общая логика между usecases? Дублировать? Или выносить в еще одну костыль-абстракцию?

Я как раз это и разбирал в разделе “Нюансы написания Use Cases на практике”. И там оставил как раз вопрос про best practice написания юскейсов, может вы знаете?

Domain model в моём примере не пытается инкапсулировать вообще весь бизнес процесс системы.

Не понял почему "не самодостаточна даже для соблюдения своих собственных инвариантов", можете подробнее раскрыть, пожалуйста.

Ну смотрите, давайте от обратного - зачем нам вообще какие-либо бизнес -правила описывать в модели? Вполне разумные преимущества:
1.концентрируем логику в одном месте - из какого бы участка кода мы не вызвали сущность, нельзя привести ее в неправильное состояние
2.вместо изменения отдельных полей мы делаем осмысленные операции с валидацией состояний и прочим
Звучит здорово, но ломается в то что это полумера. Сущность неполноценна. Ну допустим, мы зашили в ней правила касаемо ее полей. А какой в этом толк?
Вот есть операция - подписать. В ней мы меняем поля signature, signer, signed_at, предварительно проверил состояние подписываемого объекта. красиво. А потом в проверку приходиться вставлять запрос и толк от этой схемы стремиться к нулю, потому что из любого места в коде можно сделать подпись и сохранить а внешние проверки застряли в юзкейсе. При этом мы разнесли логику по разным классам, ходи потом бегай и собирай по кускам как делаться подпись. Решительных преимуществ не получили а вот недостатки в полной мере.

Теперь сравните с классической схемой когда все это будет написано в сервисе. В сервисе будет представлена вся логика работы с доменом от и до и без ограничений. Вы нажимаете метод sign и в нем вся бизнес логика, все инварианты и проверки, все походы в БД и во все интеграции. Все сконцентрировано в одном месте. Если сущность будет глупой но Вы просто запретите менять ее поля в любом месте кроме ее сервиса то получите концентрацию логики в одном месте без всех этих проблем и костылей.

Сервисный слой(use cases) в любом случае остаётся, вопрос тут скорее не "сервис или нет", а в том, где живут бизнес операции, относящиеся к конкретной сущности.
Если такая логика находится в сервисах, то при росте системы мы получаем дублирование правил или вынуждены выносить их в хелперы, что увеличивает связность.
Например, допустим у нас есть invoice.signers, и сегодня это set, потому что нам важна уникальность. Завтра мы меняем его на list, потому что появилась необходимость хранить порядок подписей.
Если логика проверки "уже подписывал или нет" находится в нескольких сервисах, нам придётся менять её во всех этих местах.
Если же локальные бизнес-операции сущности инкапсулированы внутри сущности (например, invoice.add_signer() или invoice.is_signed_by(user)), то изменение set -> list затрагивает только саму сущность, а внешний код остаётся стабильным

Сервисный слой(use cases) в любом случае остаётся, вопрос тут скорее не "сервис или нет", а в том, где живут бизнес операции, относящиеся к конкретной сущности.Если такая логика находится в сервисах, то при росте системы мы получаем дублирование правил или вынуждены выносить их в хелперы, что увеличивает связность.

Не получаем. Все бизнес-операции сущности живут в сервисе сущности и больше нигде.

Если логика проверки "уже подписывал или нет" находится в нескольких сервисах, нам придётся менять её во всех этих местах.Если же локальные бизнес-операции сущности инкапсулированы внутри сущности (например, invoice.add_signer() или invoice.is_signed_by(user)), то изменение set -> list затрагивает только саму сущность, а внешний код остаётся стабильным

Как я уже написал - логика не будет в нескольких сервисах.

В Вашем примере данная логика работает ровно до той поры пока там нет работы ввода вывода. Если логика проверки подписи требует сходить в другую систему, или хотя бы в БД, то метод сущности превращается в тыкву и все нужно переделывать и решать вопрос с перед-использованием. Вариант с управляющим сервисом такой проблемы лишен.

Еще раз пишу, в который раз.

Сущность может оперировать только своими собственными атрибутами. Она не имеет права обращаться к другим сущностям, которые не хранятся в её полях, и уж тем более использовать внешние ресурсы или инфраструктуру (БД, API, файловую систему и т.д.). Любая логика, требующая проверки или изменения других объектов, должна выноситься в сервис или use case. Это фундаментальный принцип объектно-ориентированного дизайна: сущность отвечает только за себя, а не за весь мир.

Разве в примере этого не видно?

Толку от переноса такой локальной логики - мало. Если проверка подписания - это проверка сущности и проверка в БД то мне ни горячо ни холодно от того что какой то кусок логики лежит в сущности. Мне все-равно нужно искать способы шарить логику связанную с походами в БД. А потом когда я зашарю логику usecase мне нужно дружить с отдельно зашареной логикой из сущности. Вопрос зачем мне все это если я просто могу написать метод который в себе будет содержать вообще все что для этого нужно?

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

PS. Потом будет еще веселей, когда захотите реагировать на события. Будете городить городьбу внутри сущности, возвращать из нее события, пришивать эти события к юзкейсам и прочий мрак. Знаем проходили.

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

Это очень спорная тема, думаю, что нет смысла продолжать ее.

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

Все просто, поток примерно такой:

  • Транспортный слой - consumer получает событие из внешней системы

  • Он вызывает use case, передавая данные события.

  • Use case выполняет всю бизнес-логику: проверяет состояние сущностей, модифицирует их, взаимодействует с внешними системами, при необходимости генерирует события для других сервисов

  • Entity внутри use case - только контейнер для состояния и локальных процессов, никаких интеграций нет

# вот consumer:
async def handle_invoice_signed(
    event: InvoiceSignedEvent,
    use_case: FromInjector[SignInvoiceUseCase],
):
  await use_case.execute(invoice_id=event.invoice_id, user_id=event.user_id)


  
# __________________________________________________________
# вот юскейс:
class SignInvoiceUseCase:
    def __init__(self, uow: IUoW, publisher: EventPublisher):
        self.uow = uow
        self.publisher = publisher

    async def execute(self, invoice_id: UUID, user_id: UUID):
        async with self.uow:
            invoice = await self.uow.invoice_gate.get_by_id(invoice_id)

            # Локальная логика сущности, не знает про внешние события
            invoice.add_signer(user_id)

            await self.uow.invoice_gate.save(invoice)
            await self.uow.commit()

            await self.publisher.publish(
                invoice_exchange, InvoiceSignedNotification(invoice_id=invoice.id)
            )

Сущность не знает про event bus или внешние сервисы

И пример антипаттерна, которого мы избегаем:

# невероятный антипаттерн
@dataclass
class Invoice:
    id: UUID
    signers: set[UUID]

    def add_signeyr(self, user_id: UUID):
        self.signers.add(user_id)
        event_bus.publish(InvoiceSignedNotification(self.id))

В этом антипаттерне сущность сама знает про внешнюю систему event_bus - это приводит к сильной связанности и ломает принцип Clean Architecture

примеры игрушечные, но концепция, наверное, стала понятной.

Будете городить городьбу внутри сущности, возвращать из нее события

и еще тут немного дополню - сущность никак не должна знать про какие-то инфрастуктурные евенты, это не ее зона ответственности

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

Спасибо, что заметили поверхностность. Сейчас исправлю с "Доменная модель описывает бизнес-сущность и её правила." на "Доменная модель описывает бизнес-сущность и её локальные правила". Также добавлю небольшое уточнение

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

можно решить с помощью ретраев, можно сделать outbox pattern с кроном, да много чего еще. Но это выходит за рамки этой статьи. Тут только про clean architecture без доп сложностей

А почему тут использован uow, а не репозиторий для бд?

Репозиторий отвечает только за одну сущность - save, update_status, delete, etc.
Uow отвечает за транзакцию и координирует несколько репозиториев в рамках одной транзакции. Это тоже про зону ответственности. По идее можно упростить и передовать sessionmaker и нужные репозитории в юскейс и там уже координировать

но термин sessionmaker не такой явный, в разных контекстах это называется по разному - например, DbContext, EntityManagerFactory, etc. Uow более универсален и сразу понятен читателю

передавать* =) что-то с утра плохо соображаю

может сделаю в рамках другой статьи, но тема слишком слишком широкая

Sign up to leave a comment.

Articles