Введение: «Синдром одного файла»

FastAPI подкупает своей лаконичностью. Вы копируете пример из документации, и всё работает. Но когда проект вырастает из «Hello World» в реальный сервис, эта простота становится ловушкой.

Файл main.py быстро разрастается до тысяч строк. В одной куче оказываются модели базы данных, Pydantic-схемы, бизнес-логика и роуты. Ориентироваться в этом невозможно, а любая попытка командной работы превращается в бесконечные конфликты при слиянии кода.

Новички интуитивно боятся разбивать код на модули, опасаясь сломать магию фреймворка. Им кажется безопаснее держать всё на виду в одном файле.

Сегодня мы поборем этот страх. Мы возьмем типичный монолит и превратим его в чистую, расширяемую архитектуру с помощью APIRouter, которая подходит для продакшена (при условии замены SQLite на PostgreSQL или другую корпоративную СУБД для реальных данных)

Часть 1. APIRouter: Разделяй и властвуй

Главный враг любого растущего проекта — это хаос. Когда весь код (модели, логика, настройки) лежит в одной куче в main.py, работать с ним становится невозможно. Чтобы найти нужную функцию, приходится бесконечно листать код вверх-вниз, а слияние веток в Git превращается в ад.

В FastAPI для наведения порядка используется класс APIRouter.

Простыми словами, APIRouter — это «мини-приложение». Вы можете создать несколько таких мини-приложений (одно для пользователей, другое для товаров), настроить их отдельно, а потом просто подключить к главному объекту FastAPI.

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

Шаг 1. Организация папок

Порядок в коде начинается с порядка в файловой системе. Вместо того чтобы держать всё в корне, мы создадим специальную папку routers (в некоторых командах её называют endpoints или api).

Внутри мы сгруппируем файлы по доменным областям:

  • routers/users.py — здесь будет жить всё, что касается регистрации и профилей.

  • routers/items.py — здесь будет логика работы с товарами.

Шаг 2. Создаем роутер

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

# app/routers/users.py
from fastapi import APIRouter

# Создаем экземпляр роутера.
# prefix="/users" — это пространство имен. Все маршруты в этом файле 
# автоматически получат этот префикс.
# tags=["Users"] — нужно для группировки в автоматической документации (Swagger UI).
router = APIRouter(
    prefix="/users",
    tags=["Users"]
)

# ВАЖНО: Мы используем декоратор @router, а не @app.
# Переменной app здесь не существует, мы находимся в изолированном модуле.

@router.get("/")
def get_users() -> list[dict]:
    # Итоговый путь будет: GET /users/
    # Нам не нужно писать "/users" руками, префикс подставится сам.
    return [{"username": "Rick"}, {"username": "Morty"}]

@router.get("/me")
def get_current_user() -> dict: 
    return {"username": "Rick", "role": "admin"}

Обратите внимание: код стал чище. Мы избавились от дублирования /users в каждой строчке. Если в будущем бизнес решит поменять адрес ресурса на /profiles, мы изменим это только в одном месте — в настройке prefix.

Шаг 3. Собираем приложение

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

# app/main.py
from fastapi import FastAPI
# Импортируем наш новый модуль с роутером
from app.routers import users

app = FastAPI(title="My Architecture App")

# Подключаем роутер к главному приложению.
# Это похоже на подключение плагина.
app.include_router(users.router)

@app.get("/")
def root():
    return {"message": "Приложение работает!"}

Что мы получили в итоге:

  1. Изоляция: Файл users.py отвечает только за пользователей. Его легко читать, легко править и легче покрывать тестами.

  2. Удобная документация: В Swagger UI (по адресу /docs) все методы теперь аккуратно разложены по группам благодаря параметру tags.

  3. Командная работа: Разные разработчики могут работать над разными файлами (один делает пользователей, другой — товары), не мешая друг другу и минимизируя конфликты при слиянии кода (Merge Conflicts).

Именно так строятся поддерживаемые системы: каждый компонент находится на своем месте и выполняет свою задачу.

Часть 2. Модели vs Схемы (Schemas vs Models)

Самый частый вопрос:
«Зачем мне создавать два почти одинаковых класса? У меня есть класс User для базы данных, зачем мне писать еще один такой же для Pydantic? Это же дублирование кода!»

На первый взгляд, претензия справедлива. Но в профессиональной разработке действует правило Разделения ответственности (Separation of Concerns). Нам нужно четко разделить то, как данные хранятся в базе, и то, как они передаются по сети.

Для этого мы разнесем код по двум папкам:

  • app/models/ — здесь живут SQLAlchemy Models. Это проекция таблиц базы данных.

  • app/schemas/ — здесь живут Pydantic Schemas (в других языках их часто называют DTO — Data Transfer Objects). Это правила валидации того, что мы получаем от клиента и что отдаем ему обратно.

Почему нельзя использовать один класс?

Представьте, что вы используете один класс и для базы, и для API.
У вашего пользователя в базе есть поля: id, username, hashed_password, created_at.

Если вы вернете этот объект напрямую в API:

  1. Утечка безопасности: Вы отдадите фронтенду поле hashed_password. Хакеры скажут вам спасибо.

  2. Проблема валидации: Базе данных всё равно, красивый у вас email или нет, ей важен только тип данных (String). А API должно проверить формат, длину и на��ичие @.

1. Models (Внутренняя кухня)

Создадим файл app/models/user.py. Этот код отвечает за то, как данные лежат на жестком диске сервера.

# app/models/user.py
from sqlalchemy import Column, Integer, String, Boolean
# Импортируем Base из нашего файла настроек БД (который мы исправили ранее)
from app.database import Base 

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    email = Column(String, unique=True, index=True)
    # ВАЖНО: В базе мы храним хэш пароля, а не сам пароль!
    hashed_password = Column(String)
    is_active = Column(Boolean, default=True)

2. Schemas (Публичный контракт)

Теперь создадим файл app/schemas/user.py. Этот код отвечает за общение с внешним миром. Нам часто нужно несколько схем для одной сущности, чтобы показывать разным пользователям разные наборы полей.

# app/schemas/user.py
from pydantic import BaseModel, EmailStr

# Базовая схема с общими полями, которые есть всегда
class UserBase(BaseModel):
    email: EmailStr

# Схема для СОЗДАНИЯ пользователя (Input DTO)
# Клиент отправляет нам эти данные
class UserCreate(UserBase):
    password: str # Клиент шлет сырой пароль, мы его валидируем здесь

# Схема для ОТВЕТА (Output DTO)
# Эти данные мы отдаем клиенту
class UserResponse(UserBase):
    id: int
    is_active: bool
    
    # Обратите внимание: поля password здесь НЕТ. 
    # Мы отфильтровали его на уровне схемы. Pydantic просто проигнорирует его
    # при формировании JSON, даже если в модели базы данных оно есть.

    # Эта настройка нужна, чтобы Pydantic мог читать данные 
    # прямо из объектов SQLAlchemy (ORM-модели).
    # P.S. В старых версиях Pydantic v1 это называлось 'orm_mode = True'.
    # Мы же используем Pydantic v2
    class Config:
        from_attributes = True

Как это работает вместе?

В вашем роутере (routers/users.py) происходит магия преобразования:

  1. Вы получаете данные типа UserCreate. Pydantic проверяет, что email настоящий, а password — строка.

  2. Вы берете пароль, хешируете его (никогда не храните пароли в открытом виде!) и сохраняете в User (SQLAlchemy модель).

  3. Вы возвращаете созданный объект User, но в декораторе роутера указываете response_model=UserResponse.

  4. FastAPI автоматически возьмет вашу модель из базы, прогонит через схему UserResponse и выкинет всё лишнее (например, hashed_password).

Итог:
Разделяя models и schemas, вы получаете безопасное и гибкое приложение. База данных хранит всё, что нужно ей (хеши, служебные флаги, даты обновлений), а клиент получает только тот, «чистый» JSON, который вы разрешили ему видеть.

Часть 3. Структура проекта (Tree View)

Мы обсудили роутеры, модели и схемы. Теперь давайте соберем все эти детали в единую картину.

Правильная структура папок — это скелет вашего приложения. Если скелет кривой, «мышцы» (логику) на него наращивать будет больно. В сообществе Python/FastAPI сложился определенный стандарт, который понятен большинству разработчиков.

Вот как должен выглядеть ваш проект на диске, когда вы закончите рефакторинг:

📂 my_super_project/
├── 📂 app/                  # Основной код приложения (Python-пакет)
│   ├── 📜 __init__.py       # Делает папку пакетом. Обычно пустой.
│   ├── 📜 main.py           # Точка входа. Здесь создается app = FastAPI()
│   ├── 📜 database.py       # Настройки подключения к БД (engine, SessionLocal)
│   │
│   ├── 📂 routers/          # Наши "контроллеры" (обработчики путей)
│   │   ├── 📜 __init__.py
│   │   ├── 📜 users.py      # Роутер для пользователей
│   │   └── 📜 items.py      # Роутер для товаров
│   │
│   ├── 📂 models/           # SQLAlchemy модели (структура БД)
│   │   ├── 📜 __init__.py
│   │   └── 📜 user.py
│   │
│   └── 📂 schemas/          # Pydantic схемы (валидация данных/DTO)
│       ├── 📜 __init__.py
│       └── 📜 user.py
│
├── 📜 .env                  # Секретные переменные (пароли, токены)
├── 📜 .gitignore            # Список того, что НЕЛЬЗЯ грузить в Git
└── 📜 requirements.txt      # Список зависимостей

Почему именно так?

  1. Папка app/:
    Мы прячем весь исходный код в папку app, чтобы отделить его от конфигурационных файлов в корне (.env, Dockerfile, alembic.ini). Это помогает избежать путаницы и делает импорты чистыми и понятными.

    • Правильный импорт теперь выглядит так: from app.models.user import User.

  2. Файлы __init__.py:
    Они подсказывают интерпретатору Python, что эта папка — это пакет, из которого можно импортировать модули. Даже если файл пустой, он должен там быть.

  3. Файл database.py:
    Мы не держим подключение к базе в main.py. Оно выносится в отдельный файл, чтобы его можно было импортировать и в модели, и в роутеры, не создавая циклических зависимостей (ситуация, когда файл А импортирует Б, а Б импортирует А, что ломает программу).

  4. Файл .env и .gitignore:
    Это правило безопасности №1. Пароли от базы данных, секретные ключи и токены никогда не должны храниться в коде (main.py). Они живут в файле .env, который обязательно добавляется в .gitignore.
    Если вы выложите пароли в публичный репозиторий, боты найдут их за пару секунд.

⚠️ Важный момент: Как это запускать?

Самая частая ошибка новичков при переходе на такую структуру — попытка запустить файл из папки app (например, python app/main.py). Это вызовет ошибку импорта, так как Python не будет знать о существовании пакета app.

Запускать сервер нужно из корневой папки проекта (my_super_project), используя имя пакета:

# Находимся в папке my_super_project/
uvicorn app.main:app --reload
  • app.main: означает "в папке app, файл main.py".

  • :app: означает "ищи внутри переменную с именем app (экземпляр FastAPI)".

  • --reload: перезагружать сервер при изменении кода.

Эта структура универсальна. Неважно, делаете вы маленький сервис на 5 файлов или огромную систему — этот «скелет» выдержит нагрузку и позволит вам легко масштабироваться.

Часть 4. Dependency Injection и Асинхронность

Когда мы разнесли код по разным файлам, возникает закономерный вопрос: «А как теперь работать с базой данных?»

Раньше, когда всё было в main.py, новички часто грешили созданием глобальной переменной db или открывали сессию вручную прямо внутри функции. В профессиональной архитектуре так делать нельзя.

  1. Глобальные переменные — это зло. Они делают код непредсказуемым.

  2. Синхронный код в FastAPI — это тормоз. FastAPI построен на асинхронности (async/await). Если вы используете обычные блокирующие драйверы базы данных, вы убиваете всю производительность фреймворка, превращая его в обычный Flask.

Мы сделаем всё «по взрослому»: используем асинхронный движок, Dependency Injection и драйвер aiosqlite.

Подготовка

Для работы в асинхронном режиме нам понадобится драйвер.
Добавьте его в свой проект:

pip install aiosqlite

Шаг 1. Настраиваем асинхронное подключение

В файле database.py мы инициализируем асинхронный движок. Обратите внимание: код немного отличается от старых туториалов по SQLAlchemy.

# app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase

# Важно: используем префикс sqlite+aiosqlite для асинхронной работы
SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite:///./sql_app.db"

# 1. Создаем асинхронный движок (Async Engine)
engine = create_async_engine(
    SQLALCHEMY_DATABASE_URL, 
    connect_args={"check_same_thread": False} # Нужно только для SQLite
)

# 2. Создаем фабрику асинхронных сессий.
# expire_on_commit=False — важная настройка для async, чтобы объекты не "протухали" после комита.
AsyncSessionLocal = async_sessionmaker(
    bind=engine, 
    class_=AsyncSession, 
    expire_on_commit=False
)

# 3. Базовый класс для моделей (современный синтаксис SQLAlchemy 2.0)
class Base(DeclarativeBase):
    pass

# 4. Dependency (Зависимость).
# Это асинхронный генератор.
async def get_db():
    # Используем async with — это гарантирует, что сессия закроется корректно
    async with AsyncSessionLocal() as db:
        yield db

Шаг 2. Внедряем зависимость в роутер

Теперь идем в наш routers/users.py.
Здесь два ключевых изменения:

  1. Функция стала async def.

  2. Мы используем await для операций ввода-вывода (запись в БД).

ВНИМАНИЕ: Приведенный ниже метод хеширования является исключительно демонстрационным. Использование его в реальных системах создает критическую уязвимость и нарушает стандарты безопасности (включая 152-ФЗ, GDPR). Используйте специализированные библиотеки (Passlib, Bcrypt)»

# app/routers/users.py
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db # Импортируем наш асинхронный генератор
from app import models, schemas

router = APIRouter(prefix="/users", tags=["Users"])

@router.post("/", response_model=schemas.UserResponse)
async def create_user(
    user: schemas.UserCreate, 
    # FastAPI сам вызовет get_db, дождется сессии и передаст её сюда
    db: AsyncSession = Depends(get_db) 
):
    # Хешируем пароль (в реальном проекте используйте Bcrypt!)
    fake_hashed_password = f"secret_hash_{user.password}" 

    db_user = models.User(email=user.email, hashed_password=fake_hashed_password)
    
    # Добавляем в сессию (здесь await не нужен, это операция в памяти)
    db.add(db_user)
    
    # А вот здесь мы "отпускаем" управление, пока база сохраняет данные.
    # В это время сервер может обрабатывать запросы других пользователей!
    await db.commit()
    
    # Обновляем объект данными из базы (например, получаем присвоенный ID)
    await db.refresh(db_user)
    
    return db_user

Почему это признак качественной архитектуры?

  1. Настоящая асинхронность. Используя async/await и AsyncSession, ваш сервер может обрабатывать тысячи запросов одновременно. Пока один запрос ждет ответа от диска (базы данных), процессор переключается на обработку следующего клиента. На синхронном коде сервер бы просто "завис", ожидая базу.

  2. Автоматическое управление ресурсами. Вам не нужно писать db.close(). Конструкция async with внутри get_db и механизм Depends гарантируют, что соединение вернется в пул, даже если в коде возникнет ошибка.

  3. Тестируемость (Testability). Как и в синхронном варианте, вы легко можете подменить get_db в тестах, подсунув тестовую базу данных в памяти, не меняя код роутеров.

Теперь ваш код не просто структурирован — он написан современно, эффективно и готов к высоким нагрузкам.

Часть 5. Чего здесь не хватает? (Roadmap)

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

Но архитектура — это не только красивые папки. Это инструменты, которые обеспечивают жизнь проекта. Чтобы ваш сервис мог называться Production Ready (готовым к реальной эксплуатации), одной структуры файлов недостаточно.

Вот список технологий, которые вам нужно внедрить сразу после наведения порядка в папках:

1. Миграции базы данных (Alembic)

Сейчас вы, скорее всего, создаете таблицы командой Base.metadata.create_all(). Для учебного проекта это нормально.
Но в реальности схема базы постоянно меняется: нужно добавить колонку, переименовать таблицу или изменить тип поля. Команда create_all этого не умеет — она просто создает то, чего нет.
Инструмент Alembic позволяет управлять изменениями базы так же, как Git управляет кодом. Вы всегда сможете "откатить" базу назад или безопасно накатить обновления, не потеряв данные пользователей.

2. Управление настройками (Pydantic Settings)

Мы вынесли секреты в .env. Но читать их через os.getenv("DB_PASS") — ненадежно. Что если переменной нет? Что если порт базы передан не как число, а как строка?
Используйте библиотеку pydantic-settings. Она позволит описать конфигурацию как строгий класс. Если вы забудете указать токен в .env, приложение просто не запустится и сразу скажет вам, где ошибка, вместо того чтобы падать в середине работы.

3. Docker

«На моем компьютере всё работает» — эта фраза не работает в бизнесе. Чтобы ваше приложение гарантированно запускалось на любом сервере, его нужно упаковать в контейнер. Docker стал стандартом де-факто для развертывания Python-приложений.

4. Тестирование (Pytest)

Помните, в первой части мы говорили про Dependency Injection? Это нужно не только для красоты.
Благодаря тому, что база данных подключается через Depends, мы можем легко подменить реальную базу на легкую тестовую (например, SQLite в памяти) во время запуска тестов. Это позволяет писать надежные автотесты, которые проверяют всё ваше API за секунды.

Построение правильной архитектуры — это фундамент, на котором эти инструменты будут работать идеально.

Домашнее задание

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

Задача 1. «Новый департамент» (Уровень: Новичок)

Ситуация:
Ваш проект расширяется. Появился раздел «Блог».

Задание:

  1. Создайте новый файл app/routers/blog.py.

  2. Объявите в нем APIRouter с префиксом /blog и тегом Blog.

  3. Создайте эндпоинт GET / который возвращает {"message": "Список статей"}.

  4. Подключите новый роутер в main.py так, чтобы он заработал.

Задача 2. «Секретные материалы» (Уровень: Новичок)

Ситуация:
В вашей базе данных в таблице User есть поле hashed_password и is_admin. Вы заметили, что ручка GET /users/me возвращает эти поля клиенту, а это дыра в безопасности.

Задание:

  1. Зайдите в app/schemas/user.py.

  2. Создайте схему UserPublic, в которой будут только id, username и email.

  3. Измените роутер в app/routers/users.py: укажите response_model=UserPublic в декораторе функции.

  4. Убедитесь, что пароль больше не приходит в ответе, хотя в базе он есть.

Задача 3. «Избавляемся от глобального» (Уровень: Любитель)

Ситуация:
В коде одного из роутеров обнаружился такой код:

# app/routers/items.py
from app.database import SessionLocal

@router.get("/")
def get_items():
    db = SessionLocal() # Плохо! Создаем сессию руками
    items = db.query(Item).all()
    db.close() # А если упадет ошибка выше, сессия не закроется!
    return items

Задание:
Перепишите этот код, используя механизм Dependency Injection (Depends) и функцию get_db, которую мы разбирали в статье. Код должен стать короче и безопаснее.

Задача 4. «Великое разделение» (Уровень: Любитель)

Ситуация:
У вас есть файл app/models.py, в котором свалены в кучу классы User, Post, Comment, Order. Файл стал слишком большим (500+ строк).

Задание:

  1. Удалите файл app/models.py.

  2. Создайте папку app/models/ с файлом __init__.py.

  3. Создайте отдельные файлы user.py, post.py, order.py и разнесите классы по ним.

  4. Важно: В файле app/models/__init__.py импортируйте все эти классы (например, from .user import User). Это нужно, чтобы Alembic (система миграций) могла их "увидеть".

Задача 5. «Ловушка циклических импортов» (Уровень: Про)

Ситуация:
Вы разнесли модели по файлам models/user.py и models/post.py.
В модели User вы хотите добавить связь: posts = relationship("Post").
В модели Post вы хотите добавить связь: author = relationship("User").

Если вы сделаете прямой импорт (from .post import Post внутри user.py), Python выдаст ошибку ImportError: cannot import name... из-за циклической зависимости (файлы ссылаются друг на друга).

Задание:
Настройте связи SQLAlchemy так, чтобы ошибка исчезла, не объединяя файлы обратно.
Подсказка: SQLAlchemy позволяет указывать имя класса в relationship просто как строку, не импортируя сам класс.

Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.

Уверен, у вас все получится. Вперед, к экспериментам