Привет, Хабр! Когда начинаешь новый проект на FastAPI, всё кажется простым: пара моделей Pydantic, несколько эндпоинтов — и готово. Но через полгода и 20 000 строк кода оказывается, что базовая валидация размазана по всему приложению, бизнес-логика перемешана с обращениями к БД, а тесты пишутся со скрипом.

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

1. Service Layer: Отделяем бизнес-логику от эндпоинтов

Проблема: Бизнес-логика живёт в эндпоинтах. Хотите отменить бронь? Проверка прав, обновление статуса, запись в лог, отправка уведомления — всё это в одном роутере на 50 строк. Протестировать это отдельно? Почти невозможно.

Решение: Слой сервисов — это чистые классы Python с методами, которые знают правила вашего домена, но не знают о HTTP, базе данных или внешних API.

class BookingService:
    def __init__(self, booking_repo: AbstractBookingRepository):
        self._repo = booking_repo

    async def cancel_booking(self, booking_id: UUID, user_id: UUID) -> Booking:
        """Отмена бронирования с проверкой всех бизнес-правил."""
        booking = await self._repo.get_by_id(booking_id)
        
        # Всё, что касается правил отмены - здесь
        if booking.user_id != user_id:
            raise ForbiddenError("Нельзя отменять чужие брони")
        if booking.status != BookingStatus.CONFIRMED:
            raise BusinessError("Можно отменять только подтверждённые брони")
        if not booking.is_cancellable():
            raise BusinessError("Срок отмены истёк")
        
        booking.cancel()
        await self._repo.save(booking)
        return booking

# В эндпоинте остаётся только это:
@router.delete("/{booking_id}")
async def cancel_booking(
    booking_id: UUID,
    user: User = Depends(get_current_user),
    booking_service: BookingService = Depends(get_booking_service)
):
    """Эндпоинт теперь тонкий и только обрабатывает HTTP-контракт."""
    await booking_service.cancel_booking(booking_id, user.id)
    return {"status": "cancelled"}

Что это даёт:

  • Тестируемость: Можно написать unit-тесты для BookingService, не поднимая FastAPI приложение.

  • Переиспользование: Ту же логику отмены можно вызвать из фоновой задачи или CLI.

  • Читаемость: По названию сервиса и метода сразу понятно, что происходит.

2. Dependency Injection не только для БД

Проблема: Depends() используют в основном для подключения к БД. Но его потенциал гораздо шире.

Решение: Используйте зависимости для всего, что может меняться или требует изоляции.

# 1. Получение текущего пользователя (очевидное)
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: ...

# 2. Проверка прав (часто упускают!)
async def require_admin(user: User = Depends(get_current_user)) -> User:
    if not user.is_admin:
        raise HTTPException(status_code=403)
    return user

@router.get("/stats")
async def get_stats(user: User = Depends(require_admin)): ...

# 3. Валидация входных данных сверх Pydantic
def validate_time_range(
    start: datetime = Query(...),
    end: datetime = Query(...)
) -> tuple[datetime, datetime]:
    if end <= start:
        raise HTTPException(400, "Конец должен быть после начала")
    if (end - start).days > 30:
        raise HTTPException(400, "Максимальный диапазон — 30 дней")
    return start, end

@router.get("/report")
async def get_report(
    time_range: tuple = Depends(validate_time_range)
): ...

# 4. Логирование действий
class ActionLogger:
    def __init__(self, user: User = Depends(get_current_user)):
        self.user = user
    
    async def log(self, action: str, details: dict):
        await log_to_db(user=self.user, action=action, **details)

@router.post("/booking")
async def create_booking(
    logger: ActionLogger = Depends(),
    booking_data: BookingCreate = Body(...)
):
    await logger.log("booking_created", {"data": booking_data.dict()})

Что это даёт:

  • Чистые эндпоинты: Вся побочная логика вынесена в зависимости.

  • Гибкость: Можно легко подменить реализацию для тестов (AsyncMock для ActionLogger).

  • Соблюдение SRP: Каждая зависимость делает одну вещь.

3. Кастомные статус-коды и ошибки, которые помогают фронту

Проблема: Все ошибки возвращаются как 500 или 400 с текстом, который парсится регулярками.

Решение: Создайте иерархию исключений и глобальный обработчик.

from fastapi import HTTPException
from typing import Any, Optional

class BaseAPIException(HTTPException):
    """База для всех наших исключений."""
    def __init__(
        self,
        status_code: int,
        detail: Any = None,
        error_code: Optional[str] = None,  # Машинный код ошибки
        extra: Optional[dict] = None
    ):
        super().__init__(status_code=status_code, detail=detail)
        self.error_code = error_code
        self.extra = extra or {}

# Конкретные исключения
class NotFoundException(BaseAPIException):
    def __init__(self, detail: str = "Ресурс не найден"):
        super().__init__(404, detail, error_code="not_found")

class ValidationException(BaseAPIException):
    def __init__(self, detail: str, errors: Optional[list] = None):
        super().__init__(422, detail, error_code="validation_error")
        self.extra["fields"] = errors or []

class BusinessLogicException(BaseAPIException):
    def __init__(self, detail: str):
        super().__init__(409, detail, error_code="business_error")

# Глобальный обработчик в main.py
@app.exception_handler(BaseAPIException)
async def api_exception_handler(request, exc: BaseAPIException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "code": exc.error_code,
                "message": exc.detail,
                **exc.extra
            }
        }
    )

# Использование в сервисе
async def get_booking(booking_id: UUID) -> Booking:
    booking = await repository.get(booking_id)
    if not booking:
        raise NotFoundException(f"Бронь {booking_id} не найдена")
    return booking

Что это даёт:

  • Согласованный формат ошибок: Фронтенд знает, что всегда искать поле error.code.

  • Лёгкая обработка на клиенте: Можно сделать перехватчик:

if (error.code === 'validation_error') {
  showFieldErrors(error.fields);
}
  • Безопасность: Не пробрасываются внутренние детали из исключений БД.

4. Фоновые задачи, которые не сломаются при перезагрузке

Проблема: BackgroundTasks хороши для простых операций, но если сервер упадёт до выполнения — задача потеряется навсегда.

Решение: Используем связку Redis + RQ или Celery для важных операций.

from redis import Redis
from rq import Queue
from core.config import settings

redis = Redis.from_url(settings.REDIS_URL)
task_queue = Queue("default", connection=redis)

# Простая функция, которая будет выполнена воркером
def send_booking_confirmation_email(booking_id: str, user_email: str):
    # Здесь может быть сложная логика отправки
    email_service.send(
        to=user_email,
        subject="Бронь подтверждена",
        template="booking_confirm.html",
        booking_id=booking_id
    )

# В эндпоинте
@router.post("/booking")
async def create_booking(
    booking_data: BookingCreate,
    task_queue: Queue = Depends(get_task_queue)
):
    booking = await booking_service.create(booking_data)
    
    # Отправляем задачу в очередь вместо BackgroundTasks
    task_queue.enqueue(
        send_booking_confirmation_email,
        booking_id=str(booking.id),
        user_email=booking.user_email,
        # Можно добавить отложенное выполнение
        job_timeout=30  # seconds
    )
    
    return booking

Что это даёт:

  • Надёжность: Задачи переживают перезагрузку сервера.

  • Масштабирование: Воркеры можно запускать на отдельных машинах.

  • Контроль: Есть панели мониторинга (RQ Dashboard, Flower для Celery).

5. Pydantic не только для запросов: Domain Models

Проблема: Мы используем Pydantic только для Request и Response моделей, а бизнес-сущности описываем классами с кучей свойств и методов.

Решение: Сделайте Pydantic-модели полноценными доменными объектами.

from pydantic import BaseModel, validator, root_validator
from enum import Enum
from datetime import datetime, timedelta

class BookingStatus(str, Enum):
    PENDING = "pending"
    CONFIRMED = "confirmed"
    CANCELLED = "cancelled"

class Booking(BaseModel):
    """Доменная модель бронирования."""
    id: UUID
    user_id: UUID
    resource_id: UUID
    status: BookingStatus
    starts_at: datetime
    ends_at: datetime
    
    # Валидаторы на уровне домена
    @validator('ends_at')
    def validate_duration(cls, ends_at, values):
        starts_at = values.get('starts_at')
        if starts_at and ends_at:
            if ends_at <= starts_at:
                raise ValueError('Конец должен быть после начала')
            if ends_at - starts_at > timedelta(hours=24):
                raise ValueError('Максимум 24 часа')
        return ends_at
    
    # Бизнес-методы прямо в модели
    def cancel(self):
        if self.status != BookingStatus.CONFIRMED:
            raise BusinessError("Можно отменять только подтверждённые")
        self.status = BookingStatus.CANCELLED
    
    def is_cancellable(self) -> bool:
        return (
            self.status == BookingStatus.CONFIRMED
            and self.starts_at - datetime.now() > timedelta(hours=1)
        )
    
    class Config:
        orm_mode = True  # Для удобной конвертации из SQLAlchemy
        use_enum_values = True

Что это даёт:

  • Единый источник истины: Валидация в одном месте.

  • Богатая модель: Можно добавлять методы, не создавая сервис на каждое действие.

  • Совместимость: Легко конвертировать в/из БД.

Эти паттерны не требуют переписывания всего приложения. Можно внедрять их постепенно:

  1. Начните с кастомных исключений — это 30 минут работы.

  2. При рефакторинге следующего модуля выделите сервисный слой.

  3. Когда понадобится отправка 1000 писем — поставьте Redis и очередь задач.

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

А какие архитектурные решения спасли ваши проекты? Делитесь в комментариях

Комментарии (26)


  1. Pax_Ammaria
    06.01.2026 17:30

    Зачем вы минусуете статью? В рассуждениях автора есть изъяны? Можно пояснительную бригаду? Уровень компетенций не позволяет оценить самостоятельно. Спасибо.


  1. DanielKross
    06.01.2026 17:30

    Не силен в питоне, но выглядит здраво.


    1. MrEx3cut0r Автор
      06.01.2026 17:30

      Я не силен в питоне?(


      1. March228
        06.01.2026 17:30

        Скорее всего автор коммента не силен


  1. Hastur226
    06.01.2026 17:30

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


    1. Tishka17
      06.01.2026 17:30

      1. На указанном примере непонятно от чего отделяли бизнес логику. Я полностью поддерживаю идею сервисного слоя, но в указанном примере тема не раскрыта. Что бл отделить, её надо отделять от чего-то. Тут просто не показано от чего. Ещё и возврат словарика из вьюхи - сомнительно, как и возврат доменной модели из сервисного слоя. В общем тема не раскрыта

      2. Использование Depends != Использование Dependency injection. По факту в указанном коде нет dependency injection, есть странный вызов функций с помощью помещения их в сигнатуру вместо обычного вызова. Depends удобен для другого - разделения логики парсинга запроса на части. Да, Рамирез тоже ошибается

      3. Использование pydantic в домене обсуждаемо. Я лично против. Но вот использование pydantic первый версии в статье 2026 года попахивает нейрослопом


  1. izibrizi2
    06.01.2026 17:30

    Используйте dishka. Ибо вызов таски это тоже своего рода эндпоинт, но в таск менеджерах нет di контейнеров, отсюда и.


  1. vasisafronov
    06.01.2026 17:30

    Использовать pydantic в домене это фатальная ошибка! Прям я бы сказал ошибка джуна.


    1. MrEx3cut0r Автор
      06.01.2026 17:30

      Почему?


      1. krasnov_v_i
        06.01.2026 17:30

        Проверка типов данных, валидация значений - это все задача адаптеров. Когда мы получили запрос, мы проверили, валидный ли json, типы полей и т.д. тут pydantic хорошо помогает.

        На доменном уровне мы работаем с моделями бизнеса. Мы не валидируем с технической точки зрения.

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


        1. Tishka17
          06.01.2026 17:30

          Валидация значений вполне себе задача бизнес логики. Тут вам и паттерн ValueObject и методы изменения с проверками инвариантов. Только для этого пидантик не нужен, тем более что он ещё внедряет свои неявные преобразования алиасов и типов.

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


      1. vasisafronov
        06.01.2026 17:30

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

        Но если этого недостаточно то вот еще несколько причин:

        1. Примеры в статье достаточно "плюшевые", в нормальном продакшн решении обычно не получается обойтись без конструкторов класса и других dunder-методов, которые модали pydantic при их стандартном использовании не предоставляют, дальше придется городить костыли.

        2. Сами по себе pydantic модели по умолчанию иммутабельные. Доменные модели же обычно требуют изменения состояния с сохранением инвариантов.

        3. Нет адекватной поддержки приватных атрибутов, ты всегда можешь обратиться к любому из своих полей напрямую, минуя API модели. Pydantic имеет PrivateAttr, но это не полноценная инкапсуляция. Доменная модель должна скрывать внутреннее состояние и предоставлять только безопасные методы для его изменения. Да, конечно в python приватность условная, но все же пользователям модели (тут имею в виду других разработчиков, которые не должны лезть внутрь модели чтобы использовать ее) должен быть предоставлен понятный API для взаимодействия с моделью, никакое поле не изменяется напрямую, а только через так называемые геттеры и сеттеры, которые содержат в себе различные правила взаимодействия.

        4. Валидации примеряются при создании модели, но не выполняются при изменении атрибутов модели, а значит правила проверок устанавливаемых значений все равно нужно будет реализовывать в сеттерах. Это обесценивает применение pydantic в доменных моделях.

        Ну и в целом ценность доменной модели в том, что на всех этапах своего жизненного цикла в бизнес процессе она имеет полностью ваидные значения. Используя API модели (герреты, сеттеры, и др методы) разработчик может управлять ее состоянием, но при этом он никогда не сможет задать ей не валидное состояние (состояние когда значения атрибутов противоречат бизнес логике), а pydantic никак с этим не помогает.

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

        В целом, если забить на правило что домен не должен ни от чего зависеть, то pydantic может подойти для реализации объектов-значений, но с этим также хорошо справляются и обычные датаклассы при этом не накладывают никакого оверхеда на производительность.

        P.S.
        Хочу заметить, что в целом для твоего возраста (если данные в профиле верные) и как следвтсвие опыта это хорошая стаья, несмотря на критику) Не сдавайся))


        1. MrEx3cut0r Автор
          06.01.2026 17:30

          спасибо за критику. Сейчас работаю в одиночку над проектом для огромной организации, получается придется чистить домены от pydantic моделей и переходить на dataclass, ибо у меня там одно месево из моделей.


          1. Tishka17
            06.01.2026 17:30

            Там есть второе опасное место - дублирование ORM моделей и моделей бл. Если вы так делаете, у вас будут проблемы с idm и UoW алхимии.


  1. metheoryt
    06.01.2026 17:30

    Когда тыкал на заголовок, ожидал разбор архитектуры самого FastAPI, если честно. Ну а статья… Непонятно, для кого она написана, если всё это можно узнать у чатгпт одним небольшим промптом. Может, лучше статью «5 промптов, которые я пожалел что не отправил чатгпт раньше»?


    1. metheoryt
      06.01.2026 17:30

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


      1. MrEx3cut0r Автор
        06.01.2026 17:30

        не нейроген. Статья предназначена для новичков, да и генерить тут нечего, Писать 5 минут


  1. SaberFreeze
    06.01.2026 17:30

    1) Почему сервисный слой знает статус коды HTTP слоя?

    2) Использование Pydantic моделей в домене как по мне плохая идея. Лучше датаклассы с мапером.

    3) Почему синхронный rq, а не как минимум arq?


    1. MrEx3cut0r Автор
      06.01.2026 17:30

      1) почему бы и нет? Ничего критического от этого не будет, но да, я согласен что так делать лучше не надо
      2) каждый пишет код по разному, я лично привык использовать pydantic модели.
      3) в статье я не говорю что нужно писать прям как на примере и использовать такие же библиотеки, я показываю архитектурные приемы и вы конечно можете использовать arq


      1. SaberFreeze
        06.01.2026 17:30

        1) Потому что в мире есть что-то кроме HTTP, тот же gRPC, CLI и тд. Это нарушение архитектуры. Сервисный слой не должен быть привязан к транспорту.

        2) Писать нужно правильно, а не как хочется, если вы хотите писать хороший, расширяемый код. Pydantic создан для валидации, а не для БЛ. Доменные модели не должны зависеть от сторонних библиотек и сам факт использования для нмх Pydantic вызывает много проблем.

        3) Выбор библиотек это часть архитектурных решений. Почему нельзя показать сразу правильный подход? RQ в async проекте это как "заряжать теслу от бензинового генератора".


  1. Pax_Ammaria
    06.01.2026 17:30

    Ну, кстати, да. Для новичков релевантнее было бы написать статью именно по архитектуре FastAPI. Для первых шагов.Как бы, намёк :)


    1. Tishka17
      06.01.2026 17:30

      1. MrEx3cut0r Автор
        06.01.2026 17:30

        Стоит писать статью про то как работает FastAPI под капотом?


        1. Tishka17
          06.01.2026 17:30

          Я не уверен. Там каша и большую часть делает все равно starlette.


  1. lma10h
    06.01.2026 17:30

    Я как человек недавно написавший на fastapi 5000 строк пет-проекта (родной С++), могу сказать что идея классная. Но ребята верно пишут, загонять бизнес классы под pydantic выглядит не очень, http коды в сервисе тоже не очень, но идея хорошая, и это уже про этап рефакторинга зрелого проекта, на старте, лучше так не делать, т.к. уйдете в красоту кода, а не бизнес задачи.


  1. Tim02
    06.01.2026 17:30

    Не помещайте в pydantic модели лишнюю логику. Поимеете кучу проблем потом с толстыми моделями. Даже бизнес валидации не суйте в них. Эти модели чисто для валидаций простых типов и чтоб в словарь/ json потом сконвертить.