FastAPI для новичков: как организовать структуру проекта, чтобы не писать всё в одном файле
Разложим приложение на роуты, сервисы и слой моделей, настроим зависимости и обработчики ошибок. Цель — база для дальнейшего роста без будущего “рефакторинга на боль”.
Содержание
FastAPI для новичков: как организовать структуру проекта, чтобы не писать всё в одном файле
Новички часто начинают с FastAPI так: открывают main.py, добавляют несколько роутов, пару схем Pydantic, формируют запросы в обработчиках — и через неделю получают файл на 800–1200 строк, где бизнес-логика вперемешку с HTTP-деталями, валидацией и доступом к данным. Рефакторинг превращается в боль: меняешь одну часть — ломается другая, а тестировать становится сложно.
Хорошая новость: правильно организованная структура почти всегда окупается. Она не только делает код читаемым, но и снижает риски при росте проекта — особенно когда приложение выходит за рамки “простого API”.
Ниже разберём базовую, но масштабируемую структуру проекта на FastAPI: разложим приложение на роуты, сервисы и слой моделей/схем, настроим зависимости и обработчики ошибок. Это фундамент, с которого разумно начинать, чтобы не “прибивать” будущее рефакторингом.
Почему “всё в одном файле” — это не только неудобно
FastAPI поощряет быстрое прототипирование. Это отлично для старта, но у “одного файла” есть объективные минусы:
-
Смешение уровней абстракции.
HTTP-слой (маршруты, коды статусов, формат ответа) смешивается с бизнес-правилами (что разрешено, что запрещено), а те — с доступом к данным (как именно запрашивать данные). -
Непредсказуемые зависимости.
Когда всё в одном файле, логика начинает “знать” слишком много: импорты циклятся, сложнее выделить повторяемые части, проще случайно создать скрытые зависимости. -
Усложнение тестирования.
Если вы проверяете логику только через HTTP (интеграционные тесты), то тесты становятся медленными и хрупкими. Если бизнес-логика находится в обработчиках, тестировать её изолированно почти невозможно. -
Сложность внесения изменений.
Допустим, вы меняете структуру данных или способ получения объектов. Если эти изменения “размазаны” по роутам и моделям, вы будете править много мест.
Правильная архитектура не делает приложение “идеальным”. Она делает его управляемым.
Целевая структура проекта: роуты, сервисы, модели
Рассмотрим пример простого API: управление пользователями. У нас будут:
- Router слой: принимает входные данные, вызывает сервисы, возвращает ответ.
- Service слой: содержит бизнес-логику (например, “создать пользователя”, “получить пользователя”).
- Models / Schemas слой: Pydantic-схемы для входа/выхода и (при необходимости) доменные модели.
- Dependencies: создание и внедрение зависимостей (например, репозиторий или “клиент БД”).
- Error handling: единообразная обработка ошибок.
Пример структуры директорий
app/
__init__.py
main.py
api/
__init__.py
routes/
__init__.py
users.py
core/
__init__.py
config.py
models/
__init__.py
users.py
services/
__init__.py
users.py
db/
__init__.py
session.py
repositories/
__init__.py
users.py
errors/
__init__.py
handlers.py
Вы можете адаптировать названия под себя. Главное — держать границы и назначение компонентов.
Слой моделей: Pydantic схемы без смешения логики
Сначала выделим схемы данных. В FastAPI чаще всего под “моделями” имеют в виду Pydantic-схемы: они описывают вход и выход HTTP.
app/models/users.py
from pydantic import BaseModel, EmailStr
from typing import Optional
from uuid import UUID, uuid4
class UserBase(BaseModel):
email: EmailStr
full_name: Optional[str] = None
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
full_name: Optional[str] = None
email: Optional[EmailStr] = None
class UserOut(UserBase):
id: UUID
class Config:
from_attributes = True # полезно при ORM-моделях
class UserLoginRequest(BaseModel):
email: EmailStr
password: str
Пояснение по сути:
UserCreate— то, что клиент отправляет при создании.UserOut— то, что API возвращает клиенту.UserUpdate— частичное обновление (по желанию можно сделать PATCH-логику).
Важно: в схемах нет вызовов БД, нет бизнес-логики и тем более нет деталей HTTP (статусы, Request/Response).
Слой сервисов: бизнес-логика отдельно от HTTP
Теперь создадим сервис users, который будет работать с репозиторием (здесь репозиторий условный, ниже будет пример).
app/services/users.py
from uuid import UUID
from app.db.repositories.users import UsersRepository
from app.models.users import UserCreate, UserOut, UserUpdate
class UserService:
def __init__(self, repo: UsersRepository):
self.repo = repo
async def create_user(self, payload: UserCreate) -> UserOut:
# пример бизнес-правила: email должен быть уникальным
existing = await self.repo.get_by_email(payload.email)
if existing is not None:
# не превращаем HTTP-ответ в сервисе
raise ValueError("User with this email already exists")
created = await self.repo.create(
email=payload.email,
full_name=payload.full_name,
password=payload.password,
)
return UserOut(id=created["id"], email=created["email"], full_name=created["full_name"])
async def get_user(self, user_id: UUID) -> UserOut:
user = await self.repo.get_by_id(user_id)
if user is None:
raise LookupError("User not found")
return UserOut(id=user["id"], email=user["email"], full_name=user["full_name"])
async def update_user(self, user_id: UUID, payload: UserUpdate) -> UserOut:
user = await self.repo.get_by_id(user_id)
if user is None:
raise LookupError("User not found")
updated = await self.repo.update(
user_id=user_id,
email=payload.email if payload.email is not None else user["email"],
full_name=payload.full_name if payload.full_name is not None else user["full_name"],
)
return UserOut(id=updated["id"], email=updated["email"], full_name=updated["full_name"])
Обратите внимание на ключевую идею: сервис выбрасывает исключения, а не формирует HTTP-ответы.
Да, на уровне “чистой архитектуры” можно использовать свои domain-исключения (UserAlreadyExists, UserNotFound). Но даже с базовыми исключениями важно держать границу: HTTP-слой решает, какой статус вернуть, сервис — что произошло по смыслу.
Репозиторий и доступ к данным: базовая заготовка
Поскольку вопрос статьи — структура и зависимости, сделаем репозиторий “заглушкой”, но правильно оформим границы.
app/db/repositories/users.py
from typing import Optional
from uuid import UUID, uuid4
class UsersRepository:
"""Пример интерфейса репозитория.
В реальном проекте здесь будет доступ к БД (SQLAlchemy, asyncpg, etc).
"""
def __init__(self):
# демо-хранилище
self._store = {}
async def get_by_id(self, user_id: UUID) -> Optional[dict]:
return self._store.get(str(user_id))
async def get_by_email(self, email: str) -> Optional[dict]:
for u in self._store.values():
if u["email"] == email:
return u
return None
async def create(self, email: str, full_name: str, password: str) -> dict:
user_id = uuid4()
user = {"id": user_id, "email": email, "full_name": full_name, "password": password}
self._store[str(user_id)] = user
return user
async def update(self, user_id: UUID, email: str, full_name: str) -> dict:
user = self._store[str(user_id)]
user["email"] = email
user["full_name"] = full_name
self._store[str(user_id)] = user
return user
В реальном проекте вам понадобится:
- сессия БД,
- транзакции,
- конфигурация подключения,
- вероятно, отдельный слой моделей ORM.
Но базовый каркас остаётся: роуты не должны знать детали хранилища.
Зависимости FastAPI: DI вместо ручных конструкторов
FastAPI умеет внедрять зависимости через Depends. Это тот случай, когда DI реально делает код чище, а не “усложняет”.
Создадим функцию зависимостей: репозиторий и сервис.
app/api/routes/users.py + зависимость
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, status
from app.db.repositories.users import UsersRepository
from app.models.users import UserCreate, UserOut, UserUpdate
from app.services.users import UserService
router = APIRouter(prefix="/users", tags=["users"])
def get_users_repo() -> UsersRepository:
# В реальном приложении — создание сессии/репозитория по request scope
return UsersRepository()
def get_user_service(repo: UsersRepository = Depends(get_users_repo)) -> UserService:
return UserService(repo=repo)
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
payload: UserCreate,
service: UserService = Depends(get_user_service),
):
return await service.create_user(payload)
@router.get("/{user_id}", response_model=UserOut)
async def get_user(
user_id: UUID,
service: UserService = Depends(get_user_service),
):
return await service.get_user(user_id)
@router.patch("/{user_id}", response_model=UserOut)
async def update_user(
user_id: UUID,
payload: UserUpdate,
service: UserService = Depends(get_user_service),
):
return await service.update_user(user_id, payload)
Что здесь важно:
UsersRepositoryсоздаётся в зависимости, а не в каждом роуте.UserServiceтакже создаётся в зависимости, и роуты становятся тонкими.- Роуты читаются как “поток”: входные данные → сервис → результат.
Типичная ошибка: пытаться внедрять сервис вручную (service = UserService(...)) прямо в обработчиках. Да, это работает, но вы проигрываете гибкость (scope, тестирование, подмена зависимостей).
Единообразная обработка ошибок: чтобы сервис не “знал HTTP”
Теперь подключим обработчики ошибок. Идея:
- сервис бросает исключения,
- middleware/exception handler переводит их в понятные HTTP-ответы.
app/errors/handlers.py
from fastapi import Request
from fastapi.responses import JSONResponse
def http_error_response(status_code: int, message: str):
return JSONResponse(
status_code=status_code,
content={"detail": message},
)
Немного не хватает обработчиков. Давайте добавим конкретику:
from fastapi import Request, status
from fastapi.responses import JSONResponse
async def value_error_handler(request: Request, exc: ValueError):
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": str(exc)},
)
async def lookup_error_handler(request: Request, exc: LookupError):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": str(exc)},
)
Теперь подключим это в main.py.
main.py: склейка приложения без “мегафайла”
Файл main.py должен быть коротким и отвечать за:
- создание приложения,
- подключение роутов,
- регистрацию обработчиков ошибок,
- (опционально) CORS, логирование, startup/shutdown.
app/main.py
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.api.routes.users import router as users_router
from app.errors.handlers import value_error_handler, lookup_error_handler
app = FastAPI(title="Example API")
@app.exception_handler(ValueError)
async def value_error_exc_handler(request: Request, exc: ValueError):
return await value_error_handler(request, exc)
@app.exception_handler(LookupError)
async def lookup_error_exc_handler(request: Request, exc: LookupError):
return await lookup_error_handler(request, exc)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
# Приведём формат ошибок к более “человеческому”
return JSONResponse(
status_code=422,
content={"detail": exc.errors()},
)
app.include_router(users_router)
Обратите внимание:
- обработчики ошибок вынесены и подключаются централизованно,
main.pyне содержит бизнес-логики.
Как “правильно” масштабировать структуру дальше
Базовая архитектура хорошо выдерживает рост. Но есть нюансы, о которые часто спотыкаются на этапе “после первых роутов”.
1) Не дублируйте зависимости в каждом роуте
Если вы для каждого роутера пишете:
repo = UsersRepository()
service = UserService(repo)
— вы фактически игнорируете DI. Правильнее:
- один раз описать
get_repo()иget_service(), - переиспользовать через
Depends.
2) Не смешивайте “domain-ошибки” и “формат ответов”
Если сервис начинает возвращать JSONResponse или HTTPException, он “привязан” к HTTP. Это ломает тестирование и переносимость логики.
Вместо этого:
- сервис бросает доменные ошибки,
- exception handler преобразует их в HTTP.
3) Разделяйте Pydantic схемы входа/выхода
Частая ошибка: использовать одну схему для всего.
Например, UserOut не должен включать пароль.
А UserCreate не должен возвращаться в ответ как “всё о пользователе”.
Разные схемы — это не бюрократия, а защита от утечек и случайных несовместимостей.
4) Следите за “толщиной” сервисов
Если сервис начинает собирать сложные составные DTO, ходить в несколько репозиториев и ещё форматировать ответ под HTTP — это сигнал, что нужно разнести:
- доменную логику,
- сборку представлений,
- работу с транзакциями.
На начальном этапе можно и проще, но держите это как ориентир.
Паттерн “тонкие роуты” на практике: что именно считать тонким
“Тонкий роут” — это не про количество строк. Это про то, что роут:
- не содержит бизнес-правил,
- не знает про схему БД,
- не формирует сложные объекты из нескольких источников (по возможности),
- делегирует работу сервису.
Роут в идеале выглядит как адаптер между HTTP и внутренней логикой.
Тестирование: почему эта структура облегчает жизнь
В этой архитектуре обычно появляются как минимум два типа тестов:
-
Unit-тесты сервисов
ПодменяетеUsersRepositoryзаглушкой (in-memory или mock), проверяете бизнес-логику. -
Integration-тесты API
Проверяете, что роут правильно подключён, что валидация и exception handlers дают корректный статус и формат ответа.
Если бы всё было в одном файле, вы бы:
- тестировали много HTTP-уровня ради проверки простого правила,
- чаще сталкивались бы с “сломанными” тестами при рефакторинге деталей.
Частые подводные камни начинающих
Ошибка 1: использовать “модели” как ORM и как схемы одновременно
Иногда новички делают класс модели, который и хранит данные БД, и используется как ответ. Потом меняют БД и ломают API-формат.
Решение:
- ORM (если используете) отдельно,
- Pydantic схемы отдельно.
Ошибка 2: ловить исключения в каждом роуте
Код вроде:
try:
return await service.create_user(payload)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
— приводит к дублированию и несогласованности формата ошибок.
Решение: единый exception handler.
Ошибка 3: хранить зависимости “глобально”
Например, создать репозиторий и сервис один раз на модульном уровне. Это плохо, если зависимости содержат контекст запроса (сессии, транзакции, пользовательские данные).
Решение: DI через Depends, и scope аккуратно.
Ошибка 4: не контролировать формат ошибок валидации
По умолчанию FastAPI возвращает стандартизированный формат 422. Многие его переопределяют, но делают это частично и получают странные структуры.
Решение: если переопределяете — делайте единообразно и тестируйте.
Небольшой пример того, как это выглядит в ответах API
После запуска вы получите, например:
POST /users—201сUserOutGET /users/{id}—200или404- при попытке создать пользователя с существующим email —
409
Роуты при этом выглядят предсказ
Комментарии
Пока нет комментариев