🧙 Садовник: уточнил шаги, добавил проверки и обоснования. Примите, если полезно.

#1
open+1119proposed by gardener · Aug 4, 2026 · based on v1
Proposed changes · v1 → suggestion
+85138
1¶ ## Зачем заниматься структурой до кода
2
3 Когда файлов десять, структура кажется формальностью. Когда их девятьсот — она единственное, что позволяет найти нужное место за секунды.
4
5 Задать её сейчас стоит пятнадцать минут. Перекладывать потом сотни файлов, починяя импорты, — день работы и гарантированные ошибки.
6
7 **Важно сразу:** слоистая структура — не единственный вариант и не «правильный по определению». Есть модульная, гексагональная, плоская и другие — им посвящён отдельный список. Здесь мы берём слоистую, потому что на ней проще всего понять саму идею разделения ответственности.
8¶ ## Путь одного запроса
9
10 Вся структура существует ради этой цепочки. Запомни её — дальше всё будет про неё:
11
12 ```
13 HTTP-запрос
14
15 routers/ «пришёл POST /categories»
16
17 schemas/ «что прислали — проверено и разобрано»
18
19 services/ «можно ли так? что по правилам надо сделать?»
20
21 repository/ «сходи в базу и принеси»
22
23 models/ «так это лежит в таблицах»
24
25 база данных
26 ```
27
28 И обратно тем же путём. Каждый слой говорит **только со следующим**. Роутер не знает про базу, модель не знает про HTTP.
291## Дерево
301. Создать каркас папок
31 Внутри `app/` создай шесть папок. Пока пустых кода не будет ни строчки.
2+1. Создай каркас папок
3+ Внутри `app/` создай пять прикладных слоёв и общее ядро. Пока в них не будет прикладного кода.
324
335 ```
346 app/
357 ├── models/ таблицы базы
36 ├── schemas/ контракт API
8+ ├── schemas/ контракты данных
379 ├── repository/ доступ к данным
3810 ├── services/ бизнес-логика
3911 ├── routers/ HTTP
4012 └── core/ общее для всех
4113 ```
4214
43 На Windows в PowerShell можно одной строкой, на macOS и в Git Bash командой ниже.
44 $ mkdir -p app/{models,schemas,repository,services,routers,core}
45 why: Пустое дерево — это решение, принятое заранее. Когда появится первый файл, вопроса «куда его положить» уже не будет. Именно в этот момент обычно и начинается каша.
46 - [ ] Внутри app шесть папок
47 - [ ] Ни в одной пока нет кода
482. Положить __init__.py в каждую папку
49 Файл пустой — ему не нужно содержимое, важен сам факт его существования.
50
51 Не забудь про сам `app/__init__.py` uv его уже создал, проверь, что он на месте.
52 $ find app -type d -exec touch {}/__init__.py \;
53 why: Без него папка не является пакетом в полном смысле: её не увидит сборщик при упаковке проекта, а часть инструментов будет вести себя странно. Ошибка при этом неочевидная: модуль вроде есть, а его «нет».
54 - [ ] В каждой папке внутри app есть __init__.py
55 - [ ] Файл app/__init__.py тоже на месте
56 - [ ] uv run python -c "import app" отрабатывает без ошибки
57¶ ## Теперь разберём каждую папку
58
59 Дальше шаги не про команды, а про понимание. На каждом ответь себе на вопрос из проверок — если не получается, перечитай раздел «зачем», а не иди дальше.
15+ Команда использует Python и одинаково работает в PowerShell, macOS и Linux при установленном `uv`.
16+ $ 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')]"
17+ why: Пустое дерево — это решение, принятое заранее: когда появится первый файл, вопроса «куда его положить» уже не будет.
18+ - [ ] Внутри app созданы ровно шесть целевых папок
19+ - [ ] Названия папок совпадают со схемой
20+ - [ ] В папках пока нет прикладного кода
21+2. Добавь __init__.py в каждую папку
22+ Создай пустой `__init__.py` в самой папке `app/` и во всех шести вложенных папках. Это делает их обычными Python-пакетами и обеспечивает предсказуемое обнаружение модулей инструментами сборки и анализа.
23+ $ 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()]]]"
24+ why: Python поддерживает пакеты без `__init__.py`, но обычные пакеты надёжнее распознаются сборщиками, анализаторами и другими инструментами проекта.
25+ - [ ] В каждой целевой папке есть __init__.py
26+ - [ ] Файл app/__init__.py тоже существует
27+ - [ ] Команда uv run python -c "import app" завершается без ошибки
6028## Слои
613. models — как данные лежат в базе
62 **Что здесь:** классы SQLAlchemy — описание таблиц, колонок, связей, индексов и ограничений.
29+3. Определи роль models
30+ Здесь находятся классы SQLAlchemy: описание таблиц, колонок, связей, индексов и ограничений.
6331
6432 ```python
6533 class Category(BaseModel):
6634 __tablename__ = "categories"
6735
6836 name: Mapped[str]
6937 slug: Mapped[str] = mapped_column(unique=True, index=True)
7038 ```
7139
72 **Чего здесь быть не должно:** кодов ответа, проверок прав, вызовов внешних сервисов, отправки писем.
73 why: Модель описывает хранение и больше ничего. Привяжешь её к HTTP — и её нельзя будет использовать в фоновой задаче или скрипте, а изменение формата ответа API потянет за собой правку таблицы — то есть миграцию ради косметики.
74 - [ ] Ты можешь объяснить, почему модель не должна знать про HTTP
75 - [ ] Понимаешь, чем модель отличается от схемы
764. schemas что приходит и что уходит
77 **Что здесь:** классы Pydantic — контракт API. Отдельно на вход, отдельно на выход.
40+ Здесь не должно быть кодов HTTP-ответа, проверок прав, вызовов внешних сервисов и отправки писем.
41+ why: Модель описывает хранение и больше ничего: привязка к HTTP затруднит её использование в фоновой задаче или скрипте, а изменение API начнёт затрагивать слой базы данных.
42+ - [ ] Можешь объяснить, почему модель не должна знать про HTTP
43+ - [ ] Можешь отличить модель SQLAlchemy от схемы Pydantic
44+ - [ ] Проверил, что модели не импортируют routers и services
45+4. Определи роль schemas
46+ Здесь находятся классы Pydantic, описывающие контракты входных и выходных данных. Для создания и ответа используй отдельные схемы.
7847
7948 ```python
80 class CategoryCreate(BaseSchema): # что клиент вправе прислать
49+ class CategoryCreate(BaseSchema):
8150 name: str
8251 parent_id: int | None = None
8352
84 class CategoryResponse(BaseSchema): # что мы отдаём
53+ class CategoryResponse(BaseSchema):
8554 id: int
8655 name: str
8756 slug: str
8857 ```
8958
90 Обрати внимание: в `CategoryCreate` нет `id` и `slug` их назначает сервер.
91
92 **Чего здесь быть не должно:** запросов к базе и бизнес-правил.
93 why: Если отдавать наружу модель базы, то в день, когда в таблицу добавят поле с хешем пароля или внутренним флагом, оно уедет клиенту само. Утечка произойдёт не в момент написания эндпоинта, а через полгода и никто не свяжет одно с другим.
94 - [ ] Ты можешь объяснить, зачем разные классы на вход и на выход
95 - [ ] Понимаешь, почему id нет в схеме создания
965. repository единственное место с SQL
97 **Что здесь:** всё, что ходит в базу. Запросы, выборки, вставки, удаления.
59+ В `CategoryCreate` нет `id` и `slug`, потому что их назначает сервер. Здесь не должно быть запросов к базе и бизнес-правил.
60+ why: Раздельные схемы не позволяют случайно принять или отдать поля, которые клиент не должен изменять либо видеть.
61+ - [ ] Можешь объяснить, зачем нужны разные классы на вход и выход
62+ - [ ] Понимаешь, почему id и slug отсутствуют в схеме создания
63+ - [ ] Проверил, что схемы не обращаются к базе данных
64+5. Определи роль repository
65+ Здесь находится всё, что напрямую обращается к базе: запросы, выборки, вставки, обновления и удаления.
9866
9967 ```python
10068 async def get_by_slug(self, slug: str) -> Category | None:
10169 stmt = select(Category).where(Category.slug == slug)
10270 return await self.session.scalar(stmt)
10371 ```
10472
105 Репозиторий возвращает `None`, если не нашёл. Решать, ошибка это или нет, не его дело.
106
107 **Чего здесь быть не должно:** правил вроде «нельзя удалять категорию с товарами».
108 why: Когда весь SQL в одном слое, его можно оптимизировать, найти и заменить. Когда он размазан по роутерам и сервисам — поиск места, где запрос тормозит, превращается в археологию.
109 - [ ] Ты можешь объяснить, почему репозиторий возвращает None, а не бросает ошибку
1106. services — правила предметной области
111 **Что здесь:** то, ради чего проект существует. На примере категорий: slug генерируется из названия; нельзя удалить категорию с потомками; имя уникально внутри родителя.
73+ Репозиторий возвращает `None`, если запись не найдена. Решать, является ли это ошибкой, должен сервис. Здесь не должно быть правил вроде «нельзя удалять категорию с товарами».
74+ why: Когда весь SQL сосредоточен в одном слое, запросы проще находить, тестировать и оптимизировать.
75+ - [ ] Можешь объяснить, почему репозиторий возвращает None
76+ - [ ] Проверил, что репозиторий не создаёт HTTPException
77+ - [ ] Проверил, что SQL не размещён в routers или services
78+6. Определи роль services
79+ Здесь находятся правила предметной области. Например: slug генерируется из названия, категорию с потомками нельзя удалить, а имя должно быть уникально внутри родителя.
11280
11381 ```python
11482 async def delete(self, category_id: int) -> None:
11583 if await self.repo.has_children(category_id):
11684 raise ConflictError("У категории есть подкатегории")
11785 await self.repo.delete(category_id)
11886 ```
11987
120 Сервис бросает **своё** исключение, а не `HTTPException`.
121
122 **Чего здесь быть не должно:** кодов ответа, объекта запроса, прямых запросов к базе.
123 why: Ту же логику потом вызовет фоновая задача, импорт товаров или команда в терминале. Если правило живёт в роутере, при втором входе его либо продублируют, либо забудут. Разъехавшиеся копии одного правила ловятся очень долго.
124 - [ ] Ты можешь назвать три правила, которые относятся к сервису
125 - [ ] Понимаешь, почему сервис не бросает HTTPException
1267. routers только HTTP
127 **Что здесь:** маршруты, коды ответа, модели ответа. Роутер тонкий: принял, вызвал сервис, отдал.
88+ Сервис вызывает репозиторий и выбрасывает собственные исключения, а не `HTTPException`. Здесь не должно быть кодов ответа, объекта HTTP-запроса и прямых SQL-запросов.
89+ why: Одни и те же правила смогут вызывать HTTP-маршрут, фоновая задача, импорт данных или консольная команда без дублирования логики.
90+ - [ ] Можешь назвать три правила, относящиеся к сервису
91+ - [ ] Понимаешь, почему сервис не выбрасывает HTTPException
92+ - [ ] Проверил, что сервис обращается к базе только через repository
93+7. Определи роль routers
94+ Здесь находятся маршруты, HTTP-коды и модели ответа. Роутер должен быть тонким: принять данные, вызвать сервис и вернуть результат.
12895
12996 ```python
13097 @router.post("", status_code=201, response_model=CategoryResponse)
13198 async def create_category(
13299 data: CategoryCreate,
133100 service: CategoryServiceDep,
134101 ) -> Category:
135102 return await service.create(data)
136103 ```
137104
138 Три строки. Если в роутере появился `if` скорее всего, это логика, и ей место в сервисе.
105+ Условие в роутере повод проверить, не является ли оно бизнес-правилом. Здесь не должно быть SQL, предметных правил и локальных `try/except`, если ошибки уже преобразуются централизованными обработчиками.
106+ why: Тонкий роутер легко читать и тестировать, а вынесенная в сервис логика остаётся доступной без запуска HTTP-сервера.
107+ - [ ] Можешь объяснить, почему if в роутере требует проверки
108+ - [ ] Проверил, что роутер не обращается к базе напрямую
109+ - [ ] Проверил, что роутер вызывает сервис, а не repository
110+8. Определи роль core
111+ Здесь находятся действительно общие механизмы: настройки, базовые исключения и их обработчики, общие зависимости, безопасность и логирование.
139112
140 **Чего здесь быть не должно:** запросов к базе, бизнес-правил, `try/except` вокруг вызовов.
141 why: Логика в роутере не переиспользуется и не тестируется без поднятия сервера. Тонкий роутер можно прочитать целиком за минуту и понять всё API.
142 - [ ] Ты можешь объяснить, почему if в роутере — повод насторожиться
1438. core — то, что нужно всем
144 **Что здесь:** настройки, исключения, зависимости, безопасность, логирование.
113+ В будущем структура может выглядеть так:
145114
146115 ```
147116 core/
148 ├── settings/ конфиг из .env
149 ├── exceptions/ свои исключения и обработчики
150 ├── dependencies/ фабрики для Depends
117+ ├── settings/ конфигурация из окружения
118+ ├── exceptions/ общие исключения и обработчики
119+ ├── dependencies/ общие фабрики зависимостей
151120 └── security/ пароли и токены
152121 ```
153122
154 **Главная опасность:** `core` легко превращается в свалку для всего, чему не нашлось места. Проверка: если что-то из `core` нужно только одному слою — оно туда и должно переехать.
155 why: Слои не могут зависеть друг от друга кругово, а общие вещи нужны всем. `core` тот единственный угол, куда разрешено смотреть снизу вверх.
156 - [ ] Ты можешь назвать критерий, по которому вещь попадает в core
157 ## Правило зависимостей
158
159 Главное, что делает слои слоями, а не просто папками:
160
161 > **Слой знает только про соседа снизу и никогда — про соседа сверху.**
162
163 | Можно | Нельзя |
164 |---|---|
165 | роутер → сервис | сервис → роутер |
166 | сервис → репозиторий | репозиторий → сервис |
167 | репозиторий → модель | модель → что угодно выше |
168 | любой слой → core | core → любой слой |
169
170 Почему это важно: если зависимости идут в обе стороны, ты получаешь кольцевые импорты (Python упадёт с `ImportError`) или, что хуже, связный ком — где правка в одном месте ломает три других.
123+ Не создавай эти подпапки заранее без необходимости. Если содержимое `core` нужно только одному слою, перенеси его в этот слой.
124+ why: Общее ядро предотвращает циклические зависимости, но без строгого критерия быстро превращается в свалку несвязанных утилит.
125+ - [ ] Можешь назвать критерий попадания кода в core
126+ - [ ] Проверил, что core не импортирует routers или services
127+ - [ ] Не создавал вложенные папки без реальной необходимости
128+9. Зафиксируй правило зависимостей
129+ Разреши зависимостям идти только от внешних деталей к внутренним: `routers → services → repository → models`. Схемы используются на границе данных, а `core` предоставляет общие механизмы и не должен зависеть от прикладных слоёв.
171130
172 Это не изобретение FastAPI. Мартин Фаулер описывал то же самое как **[Presentation Domain Data Layering](https://martinfowler.com/bliki/PresentationDomainDataLayering.html)** разделение на представление, предметную область и данные.
131+ Обратный путь результата не означает обратного импорта: repository возвращает данные сервису, а сервис роутеру, но нижние слои по-прежнему ничего не знают о верхних.
132+ why: Однонаправленные импорты предотвращают циклы и позволяют заменять HTTP, базу данных или способ запуска без переписывания бизнес-правил.
133+ - [ ] models не импортирует repository, services или routers
134+ - [ ] repository не импортирует services или routers
135+ - [ ] services не импортирует routers
136+ - [ ] core не импортирует прикладные слои
173137## Проверка
1749. Нарисовать путь запроса на бумаге
175 Без подглядывания в этот список нарисуй цепочку для запроса «создать категорию»: кто кого вызывает и что возвращает обратно.
176
177 Потом сверь с блоком в начале списка.
178 why: Пока цепочка не укладывается в голове, каждый новый файл будет класться наугад. Рисунок от руки — самая быстрая проверка, что ты действительно понял, а не узнал текст.
179 - [ ] Цепочка нарисована по памяти
180 - [ ] Стрелки идут только в одну сторону
181 - [ ] Совпало с образцом
18210. Описать структуру в README
183 Одна строка на каждую папку, своими словами. Не копируй из этого списка смысл в том, чтобы сформулировать самому.
184 why: Объяснённое своими словами усваивается намного лучше прочитанного. Плюс это реально поможет тому, кто откроет проект впервые, — включая тебя через три месяца.
138+10. Нарисуй путь запроса на бумаге
139+ Без подглядывания нарисуй цепочку для запроса «создать категорию»: какие слои вызываются, какие данные передаются вперёд и что возвращается обратно. Отдельно отметь направление импортов, чтобы не смешивать его с обратным движением результата.
140+ why: Рисунок быстро показывает, действительно ли понятны границы слоёв и отличие потока выполнения от направления зависимостей.
141+ - [ ] Цепочка запроса нарисована по памяти
142+ - [ ] Вызовы идут от routers к services и repository
143+ - [ ] Направление импортов не нарушает правило зависимостей
144+ - [ ] Результат возвращается обратно без обратных импортов
145+11. Опиши структуру в README
146+ Добавь раздел «Структура проекта» и опиши каждую папку одной строкой своими словами. Отдельно запиши правило допустимого направления зависимостей.
147+ why: Собственная формулировка закрепляет понимание и помогает любому, кто впервые откроет проект, быстро найти нужный слой.
185148 - [ ] В README есть раздел «Структура проекта»
186 - [ ] Каждая папка описана одной строкой
187 - [ ] Формулировки твои, а не скопированные
188 ## Как это в бою
189
190 В боевом проекте-ориентире — ровно эти же папки: `models`, `schemas`, `repository`, `services`, `routers`, `core`. Плюс седьмая — `infrastructure`, для внешних систем: хранилище файлов, очереди, внешние API.
191
192 Разница только в наполнении: там 978 файлов `.py` против твоих шести пустых папок. Структура одна и та же — и именно поэтому в нём можно сориентироваться.
193
194 Важное честное замечание: на таком масштабе у слоистой структуры появляется минус — чтобы добавить одну функцию, надо править файлы в пяти разных папках. Отсюда растут другие подходы — им посвящён отдельный список.
195
196
197
198¶ ## Готово
199
200 Дерево создано, роли распределены, правило зависимостей понятно. Кода по-прежнему нет — и это нормально.
201
202 **Перед тем как идти дальше,** пройди список «Какие вообще бывают структуры Python-проектов». Важно понимать, что мы выбрали один вариант из нескольких и у него есть цена, — иначе легко решить, что слои бывают всегда и везде.
203
204 **Что дальше в проекте:** корневые файлы и `.env`, потом настройки, точка входа и первый эндпоинт.
149+ - [ ] Все шесть папок описаны одной строкой
150+ - [ ] Указана цепочка routers services → repository → models
151+ - [ ] Формулировки не скопированы дословно
Review