Создай каркас папокAdded
Внутри app/ создай пять прикладных слоёв и общее ядро. Пока в них не будет прикладного кода.
1app/
2├── models/ таблицы базы
3├── schemas/ контракты данных
4├── repository/ доступ к данным
5├── services/ бизнес-логика
6├── routers/ HTTP
7└── core/ общее для всех
Команда использует Python и одинаково работает в PowerShell, macOS и Linux при установленном uv.
uv run python -c "from pathlib import Path; [Path('app', name).mkdir(parents=True, exist_ok=True) for name in ('models', 'schemas', 'repository', 'services', 'routers', 'core')]"Добавь __init__.py в каждую папкуAdded
Создай пустой __init__.py в самой папке app/ и во всех шести вложенных папках. Это делает их обычными Python-пакетами и обеспечивает предсказуемое обнаружение модулей инструментами сборки и анализа.
uv run python -c "from pathlib import Path; [p.joinpath('__init__.py').touch(exist_ok=True) for p in [Path('app'), *[x for x in Path('app').iterdir() if x.is_dir()]]]"Определи роль modelsAdded
Здесь находятся классы SQLAlchemy: описание таблиц, колонок, связей, индексов и ограничений.
1class Category(BaseModel):
2 __tablename__ = "categories"
3
4 name: Mapped[str]
5 slug: Mapped[str] = mapped_column(unique=True, index=True)
Здесь не должно быть кодов HTTP-ответа, проверок прав, вызовов внешних сервисов и отправки писем.
Определи роль schemasAdded
Здесь находятся классы Pydantic, описывающие контракты входных и выходных данных. Для создания и ответа используй отдельные схемы.
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, потому что их назначает сервер. Здесь не должно быть запросов к базе и бизнес-правил.
Определи роль repositoryAdded
Здесь находится всё, что напрямую обращается к базе: запросы, выборки, вставки, обновления и удаления.
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, если запись не найдена. Решать, является ли это ошибкой, должен сервис. Здесь не должно быть правил вроде «нельзя удалять категорию с товарами».
Определи роль servicesAdded
Здесь находятся правила предметной области. Например: slug генерируется из названия, категорию с потомками нельзя удалить, а имя должно быть уникально внутри родителя.
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. Здесь не должно быть кодов ответа, объекта HTTP-запроса и прямых SQL-запросов.
Определи роль routersAdded
Здесь находятся маршруты, HTTP-коды и модели ответа. Роутер должен быть тонким: принять данные, вызвать сервис и вернуть результат.
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)
Условие в роутере — повод проверить, не является ли оно бизнес-правилом. Здесь не должно быть SQL, предметных правил и локальных try/except, если ошибки уже преобразуются централизованными обработчиками.
Определи роль coreAdded
Здесь находятся действительно общие механизмы: настройки, базовые исключения и их обработчики, общие зависимости, безопасность и логирование.
В будущем структура может выглядеть так:
1core/
2├── settings/ конфигурация из окружения
3├── exceptions/ общие исключения и обработчики
4├── dependencies/ общие фабрики зависимостей
5└── security/ пароли и токены
Не создавай эти подпапки заранее без необходимости. Если содержимое core нужно только одному слою, перенеси его в этот слой.
Зафиксируй правило зависимостейAdded
Разреши зависимостям идти только от внешних деталей к внутренним: routers → services → repository → models. Схемы используются на границе данных, а core предоставляет общие механизмы и не должен зависеть от прикладных слоёв.
Обратный путь результата не означает обратного импорта: repository возвращает данные сервису, а сервис — роутеру, но нижние слои по-прежнему ничего не знают о верхних.
Нарисуй путь запроса на бумагеAdded
Без подглядывания нарисуй цепочку для запроса «создать категорию»: какие слои вызываются, какие данные передаются вперёд и что возвращается обратно. Отдельно отметь направление импортов, чтобы не смешивать его с обратным движением результата.
Опиши структуру в READMEAdded
Добавь раздел «Структура проекта» и опиши каждую папку одной строкой своими словами. Отдельно запиши правило допустимого направления зависимостей.
## Зачем заниматься структурой до кодаRemoved
## Зачем заниматься структурой до кода
Когда файлов десять, структура кажется формальностью. Когда их девятьсот — она единственное, что позволяет найти нужное место за секунды.
Задать её сейчас стоит пятнадцать минут. Перекладывать потом сотни файлов, починяя импорты, — день работы и гарантированные ошибки.
**Важно сразу:** слоистая структура — не единственный вариант и не «правильный по определению». Есть модульная, гексагональная, плоская и другие — им посвящён отдельный список. Здесь мы берём слоистую, потому что на ней проще всего понять саму идею разделения ответственности.
## Путь одного запросаRemoved
## Путь одного запроса
Вся структура существует ради этой цепочки. Запомни её — дальше всё будет про неё:
```
HTTP-запрос
↓
routers/ «пришёл POST /categories»
↓
schemas/ «что прислали — проверено и разобрано»
↓
services/ «можно ли так? что по правилам надо сделать?»
↓
repository/ «сходи в базу и принеси»
↓
models/ «так это лежит в таблицах»
↓
база данных
```
И обратно тем же путём. Каждый слой говорит **только со следующим**. Роутер не знает про базу, модель не знает про HTTP.
Создать каркас папокRemoved
Внутри `app/` создай шесть папок. Пока пустых — кода не будет ни строчки.
```
app/
├── models/ таблицы базы
├── schemas/ контракт API
├── repository/ доступ к данным
├── services/ бизнес-логика
├── routers/ HTTP
└── core/ общее для всех
```
На Windows в PowerShell можно одной строкой, на macOS и в Git Bash — командой ниже.
Положить __init__.py в каждую папкуRemoved
Файл пустой — ему не нужно содержимое, важен сам факт его существования.
Не забудь про сам `app/__init__.py` — uv его уже создал, проверь, что он на месте.
## Теперь разберём каждую папкуRemoved
## Теперь разберём каждую папку
Дальше шаги не про команды, а про понимание. На каждом ответь себе на вопрос из проверок — если не получается, перечитай раздел «зачем», а не иди дальше.
models — как данные лежат в базеRemoved
**Что здесь:** классы SQLAlchemy — описание таблиц, колонок, связей, индексов и ограничений.
```python
class Category(BaseModel):
__tablename__ = "categories"
name: Mapped[str]
slug: Mapped[str] = mapped_column(unique=True, index=True)
```
**Чего здесь быть не должно:** кодов ответа, проверок прав, вызовов внешних сервисов, отправки писем.
schemas — что приходит и что уходитRemoved
**Что здесь:** классы Pydantic — контракт API. Отдельно на вход, отдельно на выход.
```python
class CategoryCreate(BaseSchema): # что клиент вправе прислать
name: str
parent_id: int | None = None
class CategoryResponse(BaseSchema): # что мы отдаём
id: int
name: str
slug: str
```
Обрати внимание: в `CategoryCreate` нет `id` и `slug` — их назначает сервер.
**Чего здесь быть не должно:** запросов к базе и бизнес-правил.
repository — единственное место с SQLRemoved
**Что здесь:** всё, что ходит в базу. Запросы, выборки, вставки, удаления.
```python
async def get_by_slug(self, slug: str) -> Category | None:
stmt = select(Category).where(Category.slug == slug)
return await self.session.scalar(stmt)
```
Репозиторий возвращает `None`, если не нашёл. Решать, ошибка это или нет, — не его дело.
**Чего здесь быть не должно:** правил вроде «нельзя удалять категорию с товарами».
services — правила предметной областиRemoved
**Что здесь:** то, ради чего проект существует. На примере категорий: slug генерируется из названия; нельзя удалить категорию с потомками; имя уникально внутри родителя.
```python
async def delete(self, category_id: int) -> None:
if await self.repo.has_children(category_id):
raise ConflictError("У категории есть подкатегории")
await self.repo.delete(category_id)
```
Сервис бросает **своё** исключение, а не `HTTPException`.
**Чего здесь быть не должно:** кодов ответа, объекта запроса, прямых запросов к базе.
routers — только HTTPRemoved
**Что здесь:** маршруты, коды ответа, модели ответа. Роутер тонкий: принял, вызвал сервис, отдал.
```python
@router.post("", status_code=201, response_model=CategoryResponse)
async def create_category(
data: CategoryCreate,
service: CategoryServiceDep,
) -> Category:
return await service.create(data)
```
Три строки. Если в роутере появился `if` — скорее всего, это логика, и ей место в сервисе.
**Чего здесь быть не должно:** запросов к базе, бизнес-правил, `try/except` вокруг вызовов.
core — то, что нужно всемRemoved
**Что здесь:** настройки, исключения, зависимости, безопасность, логирование.
```
core/
├── settings/ конфиг из .env
├── exceptions/ свои исключения и обработчики
├── dependencies/ фабрики для Depends
└── security/ пароли и токены
```
**Главная опасность:** `core` легко превращается в свалку для всего, чему не нашлось места. Проверка: если что-то из `core` нужно только одному слою — оно туда и должно переехать.
## Правило зависимостейRemoved
## Правило зависимостей
Главное, что делает слои слоями, а не просто папками:
> **Слой знает только про соседа снизу и никогда — про соседа сверху.**
| Можно | Нельзя |
|---|---|
| роутер → сервис | сервис → роутер |
| сервис → репозиторий | репозиторий → сервис |
| репозиторий → модель | модель → что угодно выше |
| любой слой → core | core → любой слой |
Почему это важно: если зависимости идут в обе стороны, ты получаешь кольцевые импорты (Python упадёт с `ImportError`) или, что хуже, связный ком — где правка в одном месте ломает три других.
Это не изобретение FastAPI. Мартин Фаулер описывал то же самое как **[Presentation Domain Data Layering](https://martinfowler.com/bliki/PresentationDomainDataLayering.html)** — разделение на представление, предметную область и данные.
Нарисовать путь запроса на бумагеRemoved
Без подглядывания в этот список нарисуй цепочку для запроса «создать категорию»: кто кого вызывает и что возвращает обратно.
Потом сверь с блоком в начале списка.
Описать структуру в READMERemoved
Одна строка на каждую папку, своими словами. Не копируй из этого списка — смысл в том, чтобы сформулировать самому.
## Как это в боюRemoved
## Как это в бою
В боевом проекте-ориентире — ровно эти же папки: `models`, `schemas`, `repository`, `services`, `routers`, `core`. Плюс седьмая — `infrastructure`, для внешних систем: хранилище файлов, очереди, внешние API.
Разница только в наполнении: там 978 файлов `.py` против твоих шести пустых папок. Структура одна и та же — и именно поэтому в нём можно сориентироваться.
Важное честное замечание: на таком масштабе у слоистой структуры появляется минус — чтобы добавить одну функцию, надо править файлы в пяти разных папках. Отсюда растут другие подходы — им посвящён отдельный список.
## ГотовоRemoved
## Готово
Дерево создано, роли распределены, правило зависимостей понятно. Кода по-прежнему нет — и это нормально.
**Перед тем как идти дальше,** пройди список «Какие вообще бывают структуры Python-проектов». Важно понимать, что мы выбрали один вариант из нескольких и у него есть цена, — иначе легко решить, что слои бывают всегда и везде.
**Что дальше в проекте:** корневые файлы и `.env`, потом настройки, точка входа и первый эндпоинт.