Структура большого проекта на FastAPI: как сделать правильно
Когда в проекте на FastAPI десятки эндпоинтов, три базы данных и пять микросервисов-потребителей, плоская структура из одного main.py превращается в кошмар при ревью и деплое. Разбираем подходы к организации кода, которые выдерживают рост команды и кодовой базы.

Почему плоская структура ломается
Типичный туториал по FastAPI начинается с одного файла: маршруты, модели, подключение к базе — всё вместе. До десяти эндпоинтов это работает. После тридцати — начинаются проблемы: циклические импорты, конфликты при мердже, невозможность запустить тесты для одного модуля без поднятия всего приложения. Корень не в FastAPI — фреймворк не навязывает структуру каталогов. Корень в том, что проект рос без неё.
Ниже — три подхода к структуре, от классического до domain-driven, с конкретными деревьями каталогов и ценой каждого решения. Материал написан для FastAPI 0.110+ и Python 3.11+, но принципы применимы к любой версии, поддерживающей APIRouter.
Подход 1: слоёная структура (layered)
Самый распространённый вариант, перекочевавший из Flask и Django. Код делится по техническим слоям: маршруты отдельно, сервисы отдельно, модели отдельно.
project/
├── app/
│ ├── __init__.py
│ ├── main.py # создание FastAPI-приложения
│ ├── config.py # настройки через pydantic-settings
│ ├── dependencies.py # общие Depends
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── users.py
│ │ ├── orders.py
│ │ └── products.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── user_service.py
│ │ ├── order_service.py
│ │ └── product_service.py
│ ├── models/ # SQLAlchemy / Tortoise ORM модели
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── order.py
│ │ └── product.py
│ ├── schemas/ # Pydantic-схемы
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── order.py
│ │ └── product.py
│ └── db.py # подключение к БД, сессия
├── tests/
├── alembic/
├── pyproject.toml
└── Dockerfile
Каждый файл в routers/ создаёт свой APIRouter и подключается в main.py через app.include_router(). Роутер вызывает сервис, сервис работает с моделью. Pydantic-схемы живут отдельно от ORM-моделей — это критично, потому что смешение приводит к утечке внутренних полей наружу.
Где ломается
При росте до 50+ эндпоинтов папка services/ разбухает, а зависимости между сервисами начинают образовывать клубок. Добавление новой фичи затрагивает четыре каталога одновременно — это замедляет ревью и повышает вероятность конфликтов в ветках.
Подход 2: модульная структура (по доменам)
Код группируется не по техническому слою, а по бизнес-домену. Каждый модуль — самодостаточная папка со своими роутерами, схемами, сервисами и моделями.
project/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── config.py
│ ├── core/ # общее: middleware, исключения, безопасность
│ │ ├── __init__.py
│ │ ├── exceptions.py
│ │ ├── security.py
│ │ └── database.py
│ ├── users/
│ │ ├── __init__.py
│ │ ├── router.py
│ │ ├── schemas.py
│ │ ├── models.py
│ │ ├── service.py
│ │ └── dependencies.py
│ ├── orders/
│ │ ├── __init__.py
│ │ ├── router.py
│ │ ├── schemas.py
│ │ ├── models.py
│ │ ├── service.py
│ │ └── dependencies.py
│ └── products/
│ └── ... # аналогичная структура
├── tests/
│ ├── users/
│ ├── orders/
│ └── products/
├── alembic/
└── pyproject.toml
Хороший модуль можно удалить из проекта, удалив одну папку и одну строку в main.py. Если после удаления приходится править пять файлов в других модулях — граница проведена не там.
Тесты повторяют структуру модулей — это позволяет запускать pytest tests/orders/ изолированно. Межмодульное взаимодействие идёт только через публичные функции сервисного слоя; модуль никогда не импортирует модель из чужого каталога напрямую.
Регистрация роутеров
В main.py подключение выглядит единообразно:
# app/main.py — FastAPI 0.110+
from fastapi import FastAPI
from app.users.router import router as users_router
from app.orders.router import router as orders_router
from app.products.router import router as products_router
def create_app() -> FastAPI:
application = FastAPI(title="Store API", version="1.0.0")
application.include_router(users_router, prefix="/users", tags=["users"])
application.include_router(orders_router, prefix="/orders", tags=["orders"])
application.include_router(products_router, prefix="/products", tags=["products"])
return application
app = create_app()
Фабричная функция create_app отделяет конфигурацию приложения от его инстанса — без неё невозможно нормально писать тесты с разными конфигами.
Подход 3: гексагональная / чистая архитектура
Добавляется слой портов и адаптеров: бизнес-логика не знает ни о FastAPI, ни о SQLAlchemy. Вместо прямого вызова ORM сервис получает абстрактный репозиторий через Depends.
На практике это оправдано, когда проект переживает смену хранилища (PostgreSQL → DynamoDB), или когда один и тот же домен обслуживает и REST, и gRPC, и очередь сообщений. Для проекта с одним API и одной базой — это оверинжиниринг, который замедлит разработку.
Сравнение подходов
| Критерий | Слоёная | Модульная | Гексагональная |
|---|---|---|---|
| Порог входа для нового разработчика | Низкий | Средний | Высокий |
| Изоляция доменов | Слабая | Хорошая | Полная |
| Скорость добавления модуля | Быстро до 10 модулей | Стабильно при любом количестве | Медленно из-за абстракций |
| Тестируемость без БД | Только через моки | Через моки сервиса | Нативно, через подмену адаптера |
| Смена ORM / хранилища | Больно | Терпимо | Безболезненно |
| Подходит для команды | 1–3 человека | 3–15 человек | 10+ человек, несколько команд |
Пять правил, которые работают при любом подходе
- Pydantic-схемы ≠ ORM-модели. Смешение — самая частая ошибка. Схема описывает контракт API, модель — структуру таблицы. Они меняются по разным причинам и в разное время.
- Настройки — через pydantic-settings, не через глобальные переменные. Класс
Settingsс валидацией типов и поддержкой.envрешает проблему «на проде другой DSN» без единогоifв коде. - Зависимости (Depends) — единственный способ передавать сессию БД, текущего пользователя и конфиг в роутеры. Прямой импорт глобального объекта сессии убивает тестируемость.
- Один модуль — один роутер — один тег в OpenAPI. Swagger-документация становится навигацией по проекту, а не свалкой из ста эндпоинтов.
- Alembic-миграции хранятся в корне, а не внутри app/. Миграции — артефакт инфраструктуры, а не часть приложения; они могут пережить полную смену структуры кода.
Управление зависимостями между модулями
Самое тонкое место модульной структуры — момент, когда orders/service.py хочет узнать баланс пользователя. Прямой импорт from app.users.service import get_balance создаёт связь. Два модуля — терпимо. Десять перекрёстных импортов — и структура теряет смысл.
Рабочих стратегий три:
- Явная зависимость через Depends. Роутер orders получает
user_serviceкак зависимость. Связь видна в сигнатуре, а не спрятана внутри импорта. - Событийная модель. Модуль orders публикует событие
OrderCreated, модуль users подписан и обновляет статистику. Для FastAPI это реализуется через фоновые задачи (BackgroundTasks) или внешнюю очередь. - Shared-слой. Общие для нескольких модулей сущности (например,
UserIDкак тип) живут вapp/core/types.py, а не в одном из доменных модулей.
Межмодульные запросы в БД через JOIN между таблицами разных доменов — антипаттерн. Если такой запрос необходим, это сигнал: либо домены разделены неверно, либо нужен отдельный read-модуль с денормализованным представлением.
Что делать дальше
Выберите подход, ближайший к текущему размеру команды и проекта — не к желаемому через два года. Модульная структура покрывает большинство случаев и переводится в гексагональную, если потребуется, без переписывания с нуля.
После выбора структуры следующие шаги:
- Настроить
ruffилиflake8-import-restrictions, чтобы линтер ловил запрещённые импорты между модулями. - Написать один интеграционный тест на модуль, поднимающий только его роутер, — это проверит, что модуль действительно изолирован.
- Зафиксировать соглашение о структуре модуля в
CONTRIBUTING.md: какие файлы обязательны, как называются, куда складывать миграции. Без этого документа второй разработчик сделает по-своему уже на первой неделе.
Документация FastAPI по структуре больших приложений — раздел Bigger Applications — хорошая отправная точка, но она показывает только слоёный вариант. Модульный и гексагональный придётся проектировать самостоятельно, отталкиваясь от границ ваших доменов, а не от примеров из туториала.
Dependency Injection (DI) — паттерн, при котором объект не создаёт свои зависимости сам, а получает их извне. В FastAPI реализован через механизм Depends(): функция-зависимость передаётся в параметрах роутера и вызывается фреймворком автоматически.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: FastAPI — Bigger Applications (официальная документация), FastAPI — Dependencies (официальная документация)
Частые вопросы
Можно ли начать с плоской структуры и перейти на модульную позже?
Как избежать циклических импортов между модулями?
Нужна ли гексагональная архитектура для REST API с одной базой данных?
А если у меня другая версия FastAPI — структура будет работать?
Как понять, что границы модулей проведены неправильно?
Что делать, если не получилось запустить проект после реструктуризации?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.