Обновить
4
0
Максим Панарьин @mpanaryin

Пользователь

Отправить сообщение

Нашёл время просмотреть проект (без запуска, в общих чертах). Вот мои мысли:

Что понравилось:

  1. Структурированный main
    Выделены роуты, админка, middleware и конфигурируемый запуск приложения.

  2. Все настройки (config/settings) собраны в одном месте:
    Я тоже об этом думал. В своём проекте выносил настройки, связанные только с авторизацией, внутрь модуля auth, думая, зачем им быть в общей "свалке", если они касаются только одной части системы. Но с другой стороны, чтобы быть последовательным, пришлось бы все настройки раскидать по модулям, и тогда структура стала бы гораздо более фрагментированной и потенциально хаотичной. Так что подход «всё в одном месте» в итоге выглядит предпочтительнее.

  3. Celery вынесен и сконфигурирован сразу как production-ready

  4. Бизнес-логика в use-case, а не в репозитории (на примере обновления пользователя):
    Логика проверки существования пользователя реализована именно на уровне use-case, а не репозитория. Скорее всего это имеет больше смысла, иначе use-case превращается просто в тонкую обёртку над репозиторием. К тому же в реальном проекте именно на этом уровне будет сосредоточена основная бизнес-логика, и хранить часть в репозитории, а часть в use-case будет странно.

  5. Отдельное спасибо за тесты. Я ранее ими не особо увлекался, поэтому увидеть их в правильном виде особенно полезно.

Что можно обсудить:

  1. Именование файлов глаголами (по аналогии с функциями):
    Такое решение видно в use-case и api-router. Обычно я так не делаю, не знаю насколько это допустимо.

  2. Использование UUID как ID в сущности User:
    Благодаря UUID можно генерировать id прямо в use-case, что позволяет создавать User до обращения к базе. Но если бы у нас была необходимость использовать integer как id, возникли бы сложности: пришлось бы делать id: int | None = None, потому что use-case не может сам сгенерировать валидный int-ID (он обычно автоинкрементный). Это делает User-сущность с необязательным ID немного странной семантически — она начинает походить на value object. Наверное, именно по этой причине я в своём проекте передавал в репозиторий специализированные сущности а-ля UserCreate.

  3. Импорт use-case через __init__.py на примере authenticate:
    Пока use-case один, это выглядит аккуратно. Но как только их станет несколько — придётся явно их выносить. Держать всё в __init__.pyв долгосрочной перспективе вряд ли удобно.

Как итог: спасибо, было интересно поглядеть на альтернативу, я подметил для себя пару интересных вещей. Если будут конкретные вопросы или темы, которые хотели бы обсудить тут или в личке, то всегда рад.

Я, наверное, не стану в десятый раз писать, что каждый сам выберет, как ему больше нравится, и что текущий проект не "руководство" к тому, как нужно делать, а лишь пример, как можно делать. Кому-то может пригодится что-то из этого проекта, кому-то нет.
И если этот проект кому-то покажется over-кодом, то я тут пару дней назад наткнулся на один проектик, уж что эти люди скажут про него и представить страшно...

Спасибо за комментарий. Думаю, вопрос о "правильности" чистой архитектуры — это вечная тема для обсуждений и у каждого есть своё мнение) Кто-то может по 5-10 лет работать через Active Record и не видеть никакой проблемы.

Я лишь стараюсь подчеркнуть важную мысль: архитектура — это прежде всего осознанный выбор. Если решение принято, его нужно уметь обосновать: почему именно так, а не потому что "по-другому не умею".

Что касается использования pydantic в domain в моём случае это сознательное допущение: мне просто спокойнее, когда данные валидируются на любом уровне, и я могу удобно с ними работать, не повторяя лишнюю логику. Это компромисс между строгостью и прагматизмом, с которым мне комфортно жить в рамках текущего проекта.
Но описанные вами проблемы действительно рано или поздно могут настигнуть, если это будет большой, долгоживущий проект. Так что тут опять просто отталкиваемся от наших целей.

На счёт репозитория - да, конечно, будет интересно глянуть

Если у вас есть возможность, дайте ссылку на свой репозиторий, который бы вы могли назвать "эталонным", было бы интересно поизучать код.

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

По своей сути этот слой действительно содержит две ответственности: контроль ввода/вывода и сборка зависимостей. Но они разделены на компоненты. Не будет случая, когда мы меняем что-то одно и обязательно меняется второе.

  • Если нам нужно будет менять логику представлений, а-ля api.py, то мы соответственно редактируем его.
    Например, мы меняем response_model. Опять же никакого влияния на dependencies.

  • Если нужно менять зависимости, то редактируем dependencies.py.
    Например, заменив реализацию PGUserUnitOfWork на FakeUserUnitOfWork это никак не отразиться на самом api.py.

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

  • Нужно менять логику представления?— идём в api.py

  • Нужно менять зависимости? — идём в соответствующий файл, только теперь это main

Но, конечно, не могу не согласиться, что теперь api.py не будет напрямую зависеть (иметь импорт) от компоновки зависимостей.

# main
def get_user_uow() -> IUserUnitOfWork:
    return PGUserUnitOfWork()

app.dependency_overrides[IUserUnitOfWork] = get_user_uow 

# presentation/api.py
@user_api_router.get("/{user_id}", response_model=UserReadDTO)
async def get_profile(user_id: int, uow: IUserUnitOfWork = Depends()):
    """
    Get user profile by ID.
    """
    return await get_user_profile(user_id, uow=uow)

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

Скорее всего это так и есть (по крайней мере я согласен), ну либо как минимум у программиста должен быть огромный опыт, чтобы делать это всё почти интуитивно.

Когда Дядя Боб написал статью "Читая архитектура" у него уже было 40+ лет коммерческого опыта. Он приводил в примеры проекты, которые теряли миллионы долларов на том, что неверно спроектировали систему. И конечно, эти системы не являются веб-приложением из трёх функций.

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

Увы, это не практическая, а теоретическая польза.
И при чём тут SPR? Который гласит:

«Модуль должен отвечать за одного и только за одного актора»

Каждый компонент в presentation выполняет строго отведенную ему роль. Это просто самый низкоуровневый слой, может есть для него более пригодное название, я бы с удовольствием посмотрел на варианты.

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

Спасибо, гляну, а можете назвать конкретные практические плюсы этого подхода? Чем он лучше того что есть сейчас, при условии, что текущий не нарушает Dependency Rule и не создаёт сборную солянку всего в main.py

почему у вас в интерфейсе появились детали реализации? интерфейс нужен для абстрагирования

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

class APIClientService(AuthMixin):

    def __init__(
        self,
        client: IAsyncHttpClient,
        source_url: str,
        headers: dict | None = None,
        auth_type: AuthType = AuthType.NO,
        username: str | None = None,
        password: str | None = None,
        token: str | None = None
    ):
        self.auth_type = auth_type
        self.username = username
        self.password = password
        self.token = token

        self.client = client
        self.source_url = source_url
        self.headers = {**(headers or {}), **self.auth_headers}

    async def request(
        self,
        method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
        endpoint: str,
        json_data: dict | None = None,
        params: dict | None = None,
        headers: dict | None = None,
        **kwargs
    ):

        headers = headers or {}
        request_params = {
            "url": urljoin(self.source_url, endpoint),
            "headers": {**self.headers, **headers},
            "json": json_data, "params": params, **kwargs
        }
        if method == "GET":
            response = await self.client.get(**request_params)
        elif method == "POST":
            response = await self.client.post(**request_params)
        elif method == "PUT":
            response = await self.client.put(**request_params)
        elif method == "DELETE":
            response = await self.client.delete(**request_params)
        elif method == "PATCH":
            response = await self.client.patch(**request_params)
        else:
            raise ValueError("Method not supported")
        response.raise_for_status()
        return response

ну вот сами сказали - один раз настроили и используется в мейн. Это часть мейна, а никакая не "общая часть".

Ну видите, это как рассудить. Вы говорите, что это часть мейна, т.е. вы хотите в main.py засунуть весь LOGGING_CONFIG и всё что ему нужно для работы? Handlers, Filters...? Нужно ли это всё держать в main.py? Я считаю, что нет. Поэтому он и вынесен.
А говорю, что "общая часть" потому что по сути этот конфиг логирования будет применен для всех логов в проекте.

архитектура описывает вообще всё приложение, утили вписываются в него. ваша утиль пересекает несколько архитектурных слоев

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

мой вариант: в каждом конкретном случае решать куда положить тот или иной кусок кода и назвать модуль согласно тому что там и за что оно отвечает, а не utils.

Можете дать ссылку на репозиторий какого-нибудь своего проекта, взглянуть о чем речь, как именно вы структурируете всё?

Вы правы — если строго по канону, то сборка зависимостей должна происходить в точке входа (main), и именно она "знает всё".

Но в FastAPI часто получается, что зависимости объявляются через Depends(...) прямо в presentation-слое, а Depends(get_user_uow) в итоге вызывает конкретную реализацию из infrastructure.

То есть технически это всё ещё прямой импорт, даже если зависимость обёрнута.
Да, можно избежать этого, если прокидывать зависимости снаружи, через контейнер, в main. Но тогда теряется часть удобства, которое FastAPI предоставляет "из коробки".

Очередной компромисс в ущерб строгости.

Забавная карикатура :) Оптимально? Конечно, нет.

Писать "чистую архитектуру" сложно и не всегда оправдано. Да и мнений, как именно "правильно", — десятки. Даже в этом простом проекте хватает своих "но", и он не следует строго канону дяди Боба — скорее своя сборная солянка.

Что на счёт такого подхода к User. В маленьких проектах всё обычно просто. Но когда код начинает расти, User может стать god-object’ом: валидирует, шлёт письма, пишет в базу, логинит… Наверное, проблемы появятся, просто не сразу и когда появятся, то возможно будет больно их править.

КАЖДАЯ реализация должна поддерживать ПРОИЗВОЛЬНО число аргументов

Да, потому что интерфейс заточен под работу с готовыми HTTP-клиентами, а не конкретную ручку. В реальности — мне нужен только URL. Всё остальное — дело конкретной реализации (aiohttp, httpx и т.п.).

ну так настройки логгинга - это не общая вещь, это максимальная частная вещь, которая относится к кода запуска

Почему нет? Это как раз общая настройка: определяю один раз и использую в main.py. Лучше так, чем размазывать частные конфигурации по слоям.

curd: сейчас это выглядит как попытка срезать углы и нарушение всех принципов описанных дальше.

Я об этом сам прямо написал в статье. Это утиль, не часть архитектуры. В основном проекте он не используется. Можно не обращать внимания.

в том, что такой функциональности исчезающе мало и ей можно дать нормальное название.

Можете предложить свой вариант. И я не говорю, что её много. Просто она может существовать.

Вы буквально перечислили несколько вещей совершенно разных по смыслу и уровню абстракции, но все запихнули в core. Почему у вас domain то в core, то отдельно я не понял снова.

Потому что core — не "ядро бизнес-логики", а место для общих вещей. Клиенты Redis/ES, настройки, константы — это shared-инфраструктура. Подмодули domain и infrastructure нужны для понимания, куда это потом ляжет.

Я делю и по смыслу (users, vacancies, core), и по слоям (domain, application, infrastructure, presentation). core — это техническая база, не предметная область. Если название смутило — возможно, стоит выбрать другое.

Если речь до сих пор про crud, то ещё ещё раз повторюсь - это не отдельный архитектурный слой. По сути это вообще рудимент и не относится к этой статье, в которой несколько раз упоминалось о его побочных эффектах.

В нём описан функционал для быстрого прототипирования CRUD операций и создания под них роутеров в FastAPI.
Пример его применения был в статье:

class VacancyService(CRUDBase, model=orm.VacancyDB):
    """
    Infrastructure-level service for low-level CRUD operations on VacancyDB.
    """

class VacancyCRUDRouter(CRUDRouter):
    crud = VacancyService()
    create_schema = VacancyCreateDTO
    update_schema = VacancyUpdateDTO
    read_schema = VacancyReadDTO
    router = APIRouter()

По итогу сгенерируется следующее:

Есть ли в этом проекте с его текущим функционалом переусложнение - несомненно. Но на чём-то же нужно было показывать пример.
Потому что логин — это не просто "ввёл и прошёл". Это:

  • валидация

  • поиск пользователя

  • проверка пароль

  • работа с токенами

  • учёт ролей

  • ограничение доступа

  • и так далее

Можно сделать проще? Конечно, можно. Хоть в одном фале напиши. Используй готовое, забей на слои. Всё будет работать.

Вы придираетесь к словам "реализуют ключевую бизнес-логику приложения", предполагая, что там на каждом уровне есть бизнес логика? Конечно, нет. Они для этого и разделены на domainapplicationinfrastructurepresentation.
Это противопоставление описанным общим-техническим, в которых бизнес-логики нет вообще.

  1. Этот "невнятный огрызок" и есть удобная сигнатура для реализации.
    Хотите строгий контракт — пожалуйста, но потеряете гибкость.

    from aiohttp.client import _RequestOptions
    
    @classmethod
    async def post(cls, url: str, **kwargs: _RequestOptions) -> aiohttp.ClientResponse:
        """
        Execute HTTP POST request.
    
        Args:
            url (str): HTTP POST request endpoint.
            **kwargs (_RequestOptions): Defaults kwargs for aiohttp request
    
        Returns:
            response: HTTP POST request response - aiohttp.ClientResponse object instance.
        """
        client = cls.get_aiohttp_client()
        response = await client.post(url, **kwargs)
        return response
  2. То как вы структурируете - ваше дело, тут описан текущий проект.
    "Базовые" не в плане "высокоуровневые", а в плане общие, то от чего можно отталкиваться.
    В core есть 2 папки: domain и infrastructure. Не сложно будет понять, что это переиспользуемые функции для всего проекта. В entity определена базовая pydantic схема, в infrastructure - то, что свойственно ей: клиенты, настроки... Они не меняются от модуля к модулю и их где-то нужно хранить.

  3. crud - компромисс, и он задокументирован. Это FastAPI-утилита, а не архитектурный слой. В основном проекте не используется. Просто утиль, который может быть полезен, если кому-то лень писать однотипные ручки руками.

  4. utils - просто переиспользуемый функционал, который сложно отнести к конкретному модулю. Что в нём плохого?

"прямая зависимость presentation -> infrastructure представляется маловероятной."
На уровне presentation идёт сборка зависимостей, подобно вот такой:

# presentation/dependencies.py
from typing import Annotated

from fastapi import Depends

from src.users.domain.interfaces.user_uow import IUserUnitOfWork
from src.users.infrastructure.db.unit_of_work import PGUserUnitOfWork


def get_user_uow() -> IUserUnitOfWork:
    """
    Dependency that provides an instance of IUserUnitOfWork.

    This allows the presentation layer to remain decoupled from the actual implementation.
    By default, it returns a PostgreSQL-based unit of work (PGUserUnitOfWork), but the implementation
    can be easily overridden for testing or different environments.

    :return: IUserUnitOfWork instance.
    """
    return PGUserUnitOfWork()


UserUoWDep = Annotated[IUserUnitOfWork, Depends(get_user_uow)]

# presentation/api.py
from src.users.application.use_cases.user_profile import get_user_profile
from src.users.presentation.dependencies import UserUoWDep


@user_api_router.get("/{user_id}", response_model=UserReadDTO)
async def get_profile(user_id: int, uow: UserUoWDep):
    """
    Get user profile by ID.
    """
    return await get_user_profile(user_id, uow=uow)

Что это, если не прямая зависимость от инфраструктуры "presentation -> infrastructure"?

Я добавлял описание в docstring специально для подсказок IDE при наведении на класс. Когда в коде появляется схема вроде UserUpdate, без перехода к определению непонятно, какие там поля. А такой docstring позволяет сразу увидеть состав — удобно при чтении и навигации.

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

Но возможно вы имели ввиду что-то другое, если будет возможность, покажите пример

Можно ли сделать всё проще и быстрее? — Конечно.

Но цель была другой: пощупать чистую архитектуру, поработать со всеми слоями. Даже на обычных CRUD'ах это даёт полезный опыт.

Этот проект не говорит «делай так всегда» . Он показывает, как можно сделать, если цель — попрактиковаться в архитектуре.

Спасибо) готовлю статью, сам проект уже написан, останется подрефакторить перед публикацией

1

Информация

В рейтинге
Не участвует
Откуда
Москва, Москва и Московская обл., Россия
Зарегистрирован
Активность

Специализация

Бэкенд разработчик, Веб-разработчик
Средний
Python
Django
FastAPI
PostgreSQL
Nginx
Redis
Docker
Clean Architecture
Английский язык