Привет, Хабр! Когда начинаешь новый проект на 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
Что это даёт:
Единый источник истины: Валидация в одном месте.
Богатая модель: Можно добавлять методы, не создавая сервис на каждое действие.
Совместимость: Легко конвертировать в/из БД.
Эти паттерны не требуют переписывания всего приложения. Можно внедрять их постепенно:
Начните с кастомных исключений — это 30 минут работы.
При рефакторинге следующего модуля выделите сервисный слой.
Когда понадобится отправка 1000 писем — поставьте Redis и очередь задач.
Главный принцип: не усложнять раньше времени, но и не бояться структурировать код, когда он начинает расти.
А какие архитектурные решения спасли ваши проекты? Делитесь в комментариях
Комментарии (26)

DanielKross
06.01.2026 17:30Не силен в питоне, но выглядит здраво.

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

Tishka17
06.01.2026 17:30На указанном примере непонятно от чего отделяли бизнес логику. Я полностью поддерживаю идею сервисного слоя, но в указанном примере тема не раскрыта. Что бл отделить, её надо отделять от чего-то. Тут просто не показано от чего. Ещё и возврат словарика из вьюхи - сомнительно, как и возврат доменной модели из сервисного слоя. В общем тема не раскрыта
Использование Depends != Использование Dependency injection. По факту в указанном коде нет dependency injection, есть странный вызов функций с помощью помещения их в сигнатуру вместо обычного вызова. Depends удобен для другого - разделения логики парсинга запроса на части. Да, Рамирез тоже ошибается
Использование pydantic в домене обсуждаемо. Я лично против. Но вот использование pydantic первый версии в статье 2026 года попахивает нейрослопом

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

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

MrEx3cut0r Автор
06.01.2026 17:30Почему?

krasnov_v_i
06.01.2026 17:30Проверка типов данных, валидация значений - это все задача адаптеров. Когда мы получили запрос, мы проверили, валидный ли json, типы полей и т.д. тут pydantic хорошо помогает.
На доменном уровне мы работаем с моделями бизнеса. Мы не валидируем с технической точки зрения.
К тому же доменный уровень надо держать максимально чистым от зависимостей и не должен зависеть от транспорта или инструментов валидация например.

Tishka17
06.01.2026 17:30Валидация значений вполне себе задача бизнес логики. Тут вам и паттерн ValueObject и методы изменения с проверками инвариантов. Только для этого пидантик не нужен, тем более что он ещё внедряет свои неявные преобразования алиасов и типов.
Доменный уровень действительно не должен зависеть от транспорта, но никто не запрещает использовать там вспомогательные инутрменты, если они достаточно надежные и стабильные. Проблема тут в том, что pydatic модели очень хочется сразу настроить на транспорт, потому что они это могут и будет очень сложно отговаривать коллег каждый раз.

vasisafronov
06.01.2026 17:30Ну например потому-что доменный слой должен быть изолирован от инфраструктурных деталей. Зависимость от pydantic означает, что бизнес-правила твоего приложения привязаны к библиотеке для парсинга/валидации данных. Да, конечно все эти правила не в камне высечены, но конкретно с этим вроде как в индустрии в целом все согласны и тут давно нет холиваров.
Но если этого недостаточно то вот еще несколько причин:
Примеры в статье достаточно "плюшевые", в нормальном продакшн решении обычно не получается обойтись без конструкторов класса и других dunder-методов, которые модали pydantic при их стандартном использовании не предоставляют, дальше придется городить костыли.
Сами по себе pydantic модели по умолчанию иммутабельные. Доменные модели же обычно требуют изменения состояния с сохранением инвариантов.
Нет адекватной поддержки приватных атрибутов, ты всегда можешь обратиться к любому из своих полей напрямую, минуя API модели. Pydantic имеет PrivateAttr, но это не полноценная инкапсуляция. Доменная модель должна скрывать внутреннее состояние и предоставлять только безопасные методы для его изменения. Да, конечно в python приватность условная, но все же пользователям модели (тут имею в виду других разработчиков, которые не должны лезть внутрь модели чтобы использовать ее) должен быть предоставлен понятный API для взаимодействия с моделью, никакое поле не изменяется напрямую, а только через так называемые геттеры и сеттеры, которые содержат в себе различные правила взаимодействия.
Валидации примеряются при создании модели, но не выполняются при изменении атрибутов модели, а значит правила проверок устанавливаемых значений все равно нужно будет реализовывать в сеттерах. Это обесценивает применение pydantic в доменных моделях.
Ну и в целом ценность доменной модели в том, что на всех этапах своего жизненного цикла в бизнес процессе она имеет полностью ваидные значения. Используя API модели (герреты, сеттеры, и др методы) разработчик может управлять ее состоянием, но при этом он никогда не сможет задать ей не валидное состояние (состояние когда значения атрибутов противоречат бизнес логике), а pydantic никак с этим не помогает.
Для этой задачи хорошо подходит обычный класс с конструктором, приватными атрибутами и понятными методами взаимодействия с этими атрибутами.
В целом, если забить на правило что домен не должен ни от чего зависеть, то pydantic может подойти для реализации объектов-значений, но с этим также хорошо справляются и обычные датаклассы при этом не накладывают никакого оверхеда на производительность.
P.S.
Хочу заметить, что в целом для твоего возраста (если данные в профиле верные) и как следвтсвие опыта это хорошая стаья, несмотря на критику) Не сдавайся))
MrEx3cut0r Автор
06.01.2026 17:30спасибо за критику. Сейчас работаю в одиночку над проектом для огромной организации, получается придется чистить домены от pydantic моделей и переходить на dataclass, ибо у меня там одно месево из моделей.

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

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

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

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

SaberFreeze
06.01.2026 17:301) Почему сервисный слой знает статус коды HTTP слоя?
2) Использование Pydantic моделей в домене как по мне плохая идея. Лучше датаклассы с мапером.
3) Почему синхронный rq, а не как минимум arq?

MrEx3cut0r Автор
06.01.2026 17:301) почему бы и нет? Ничего критического от этого не будет, но да, я согласен что так делать лучше не надо
2) каждый пишет код по разному, я лично привык использовать pydantic модели.
3) в статье я не говорю что нужно писать прям как на примере и использовать такие же библиотеки, я показываю архитектурные приемы и вы конечно можете использовать arq
SaberFreeze
06.01.2026 17:301) Потому что в мире есть что-то кроме HTTP, тот же gRPC, CLI и тд. Это нарушение архитектуры. Сервисный слой не должен быть привязан к транспорту.
2) Писать нужно правильно, а не как хочется, если вы хотите писать хороший, расширяемый код. Pydantic создан для валидации, а не для БЛ. Доменные модели не должны зависеть от сторонних библиотек и сам факт использования для нмх Pydantic вызывает много проблем.
3) Выбор библиотек это часть архитектурных решений. Почему нельзя показать сразу правильный подход? RQ в async проекте это как "заряжать теслу от бензинового генератора".

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

Tishka17
06.01.2026 17:30как насчет этой? https://habr.com/ru/companies/pt/articles/820171

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

Tim02
06.01.2026 17:30Не помещайте в pydantic модели лишнюю логику. Поимеете кучу проблем потом с толстыми моделями. Даже бизнес валидации не суйте в них. Эти модели чисто для валидаций простых типов и чтоб в словарь/ json потом сконвертить.
Pax_Ammaria
Зачем вы минусуете статью? В рассуждениях автора есть изъяны? Можно пояснительную бригаду? Уровень компетенций не позволяет оценить самостоятельно. Спасибо.