Структура проекта: пять слоёв внутри app и правило зависимостей

Разбираем каждую папку по отдельности: за что отвечает, что в неё класть нельзя и почему слои зависят друг от друга только в одну сторону. Создаём дерево до появления кода.

v1 0 stars 0 forks 0 watchers 1 branch 0 runs Public
mikicreated via APIv1
Зачем заниматься структурой до кода

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

Задать её сейчас стоит пятнадцать минут. Перекладывать потом сотни файлов, починяя импорты, — день работы и гарантированные ошибки.

Важно сразу: слоистая структура — не единственный вариант и не «правильный по определению». Есть модульная, гексагональная, плоская и другие — им посвящён отдельный список. Здесь мы берём слоистую, потому что на ней проще всего понять саму идею разделения ответственности.

Путь одного запроса

Вся структура существует ради этой цепочки. Запомни её — дальше всё будет про неё:

code
1HTTP-запрос
2
3routers/ «пришёл POST /categories»
4
5schemas/ «что прислали — проверено и разобрано»
6
7services/ «можно ли так? что по правилам надо сделать?»
8
9repository/ «сходи в базу и принеси»
10
11models/ «так это лежит в таблицах»
12
13база данных

И обратно тем же путём. Каждый слой говорит только со следующим. Роутер не знает про базу, модель не знает про HTTP.

Дерево

1
Создать каркас папок

Внутри app/ создай шесть папок. Пока пустых — кода не будет ни строчки.

code
1app/
2├── models/ таблицы базы
3├── schemas/ контракт API
4├── repository/ доступ к данным
5├── services/ бизнес-логика
6├── routers/ HTTP
7└── core/ общее для всех

На Windows в PowerShell можно одной строкой, на macOS и в Git Bash — командой ниже.

Why: Пустое дерево — это решение, принятое заранее. Когда появится первый файл, вопроса «куда его положить» уже не будет. Именно в этот момент обычно и начинается каша.
$mkdir -p app/{models,schemas,repository,services,routers,core}
Check
  • Внутри app шесть папок
  • Ни в одной пока нет кода
2
Положить __init__.py в каждую папку

Файл пустой — ему не нужно содержимое, важен сам факт его существования.

Не забудь про сам app/__init__.py — uv его уже создал, проверь, что он на месте.

Why: Без него папка не является пакетом в полном смысле: её не увидит сборщик при упаковке проекта, а часть инструментов будет вести себя странно. Ошибка при этом неочевидная: модуль вроде есть, а его «нет».
$find app -type d -exec touch {}/__init__.py \;
Check
  • В каждой папке внутри app есть __init__.py
  • Файл app/__init__.py тоже на месте
  • uv run python -c "import app" отрабатывает без ошибки
Теперь разберём каждую папку

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

Слои

3
models — как данные лежат в базе

Что здесь: классы SQLAlchemy — описание таблиц, колонок, связей, индексов и ограничений.

python
1class Category(BaseModel):
2 __tablename__ = "categories"
3
4 name: Mapped[str]
5 slug: Mapped[str] = mapped_column(unique=True, index=True)

Чего здесь быть не должно: кодов ответа, проверок прав, вызовов внешних сервисов, отправки писем.

Why: Модель описывает хранение и больше ничего. Привяжешь её к HTTP — и её нельзя будет использовать в фоновой задаче или скрипте, а изменение формата ответа API потянет за собой правку таблицы — то есть миграцию ради косметики.
Check
  • Ты можешь объяснить, почему модель не должна знать про HTTP
  • Понимаешь, чем модель отличается от схемы
4
schemas — что приходит и что уходит

Что здесь: классы Pydantic — контракт API. Отдельно на вход, отдельно на выход.

python
1class CategoryCreate(BaseSchema): # что клиент вправе прислать
2 name: str
3 parent_id: int | None = None
4
5class CategoryResponse(BaseSchema): # что мы отдаём
6 id: int
7 name: str
8 slug: str

Обрати внимание: в CategoryCreate нет id и slug — их назначает сервер.

Чего здесь быть не должно: запросов к базе и бизнес-правил.

Why: Если отдавать наружу модель базы, то в день, когда в таблицу добавят поле с хешем пароля или внутренним флагом, оно уедет клиенту само. Утечка произойдёт не в момент написания эндпоинта, а через полгода — и никто не свяжет одно с другим.
Check
  • Ты можешь объяснить, зачем разные классы на вход и на выход
  • Понимаешь, почему id нет в схеме создания
5
repository — единственное место с SQL

Что здесь: всё, что ходит в базу. Запросы, выборки, вставки, удаления.

python
1async def get_by_slug(self, slug: str) -> Category | None:
2 stmt = select(Category).where(Category.slug == slug)
3 return await self.session.scalar(stmt)

Репозиторий возвращает None, если не нашёл. Решать, ошибка это или нет, — не его дело.

Чего здесь быть не должно: правил вроде «нельзя удалять категорию с товарами».

Why: Когда весь SQL в одном слое, его можно оптимизировать, найти и заменить. Когда он размазан по роутерам и сервисам — поиск места, где запрос тормозит, превращается в археологию.
Check
  • Ты можешь объяснить, почему репозиторий возвращает None, а не бросает ошибку
6
services — правила предметной области

Что здесь: то, ради чего проект существует. На примере категорий: slug генерируется из названия; нельзя удалить категорию с потомками; имя уникально внутри родителя.

python
1async def delete(self, category_id: int) -> None:
2 if await self.repo.has_children(category_id):
3 raise ConflictError("У категории есть подкатегории")
4 await self.repo.delete(category_id)

Сервис бросает своё исключение, а не HTTPException.

Чего здесь быть не должно: кодов ответа, объекта запроса, прямых запросов к базе.

Why: Ту же логику потом вызовет фоновая задача, импорт товаров или команда в терминале. Если правило живёт в роутере, при втором входе его либо продублируют, либо забудут. Разъехавшиеся копии одного правила ловятся очень долго.
Check
  • Ты можешь назвать три правила, которые относятся к сервису
  • Понимаешь, почему сервис не бросает HTTPException
7
routers — только HTTP

Что здесь: маршруты, коды ответа, модели ответа. Роутер тонкий: принял, вызвал сервис, отдал.

python
1@router.post("", status_code=201, response_model=CategoryResponse)
2async def create_category(
3 data: CategoryCreate,
4 service: CategoryServiceDep,
5) -> Category:
6 return await service.create(data)

Три строки. Если в роутере появился if — скорее всего, это логика, и ей место в сервисе.

Чего здесь быть не должно: запросов к базе, бизнес-правил, try/except вокруг вызовов.

Why: Логика в роутере не переиспользуется и не тестируется без поднятия сервера. Тонкий роутер можно прочитать целиком за минуту и понять всё API.
Check
  • Ты можешь объяснить, почему if в роутере — повод насторожиться
8
core — то, что нужно всем

Что здесь: настройки, исключения, зависимости, безопасность, логирование.

code
1core/
2├── settings/ конфиг из .env
3├── exceptions/ свои исключения и обработчики
4├── dependencies/ фабрики для Depends
5└── security/ пароли и токены

Главная опасность: core легко превращается в свалку для всего, чему не нашлось места. Проверка: если что-то из core нужно только одному слою — оно туда и должно переехать.

Why: Слои не могут зависеть друг от друга кругово, а общие вещи нужны всем. `core` — тот единственный угол, куда разрешено смотреть снизу вверх.
Check
  • Ты можешь назвать критерий, по которому вещь попадает в core
Правило зависимостей

Главное, что делает слои слоями, а не просто папками:

Слой знает только про соседа снизу и никогда — про соседа сверху.

МожноНельзя
роутер → сервиссервис → роутер
сервис → репозиторийрепозиторий → сервис
репозиторий → модельмодель → что угодно выше
любой слой → corecore → любой слой

Почему это важно: если зависимости идут в обе стороны, ты получаешь кольцевые импорты (Python упадёт с ImportError) или, что хуже, связный ком — где правка в одном месте ломает три других.

Это не изобретение FastAPI. Мартин Фаулер описывал то же самое как Presentation Domain Data Layering — разделение на представление, предметную область и данные.

Проверка

9
Нарисовать путь запроса на бумаге

Без подглядывания в этот список нарисуй цепочку для запроса «создать категорию»: кто кого вызывает и что возвращает обратно.

Потом сверь с блоком в начале списка.

Why: Пока цепочка не укладывается в голове, каждый новый файл будет класться наугад. Рисунок от руки — самая быстрая проверка, что ты действительно понял, а не узнал текст.
Check
  • Цепочка нарисована по памяти
  • Стрелки идут только в одну сторону
  • Совпало с образцом
10
Описать структуру в README

Одна строка на каждую папку, своими словами. Не копируй из этого списка — смысл в том, чтобы сформулировать самому.

Why: Объяснённое своими словами усваивается намного лучше прочитанного. Плюс это реально поможет тому, кто откроет проект впервые, — включая тебя через три месяца.
Check
  • В README есть раздел «Структура проекта»
  • Каждая папка описана одной строкой
  • Формулировки твои, а не скопированные
Как это в бою

В боевом проекте-ориентире — ровно эти же папки: models, schemas, repository, services, routers, core. Плюс седьмая — infrastructure, для внешних систем: хранилище файлов, очереди, внешние API.

Разница только в наполнении: там 978 файлов .py против твоих шести пустых папок. Структура одна и та же — и именно поэтому в нём можно сориентироваться.

Важное честное замечание: на таком масштабе у слоистой структуры появляется минус — чтобы добавить одну функцию, надо править файлы в пяти разных папках. Отсюда растут другие подходы — им посвящён отдельный список.

Роутеру нужно достать категорию из базы. Почему нельзя написать SQL прямо в нём?
Зачем в каждой папке нужен пустой __init__.py?
Может ли репозиторий импортировать сервис?
Готово

Дерево создано, роли распределены, правило зависимостей понятно. Кода по-прежнему нет — и это нормально.

Перед тем как идти дальше, пройди список «Какие вообще бывают структуры Python-проектов». Важно понимать, что мы выбрали один вариант из нескольких и у него есть цена, — иначе легко решить, что слои бывают всегда и везде.

Что дальше в проекте: корневые файлы и .env, потом настройки, точка входа и первый эндпоинт.