Структура большого Django-проекта: приложения и границы
Когда в проекте 40 моделей в одном приложении и циклический импорт при каждой попытке разбить его, помогает не «правильная» файловая структура, а границы между приложениями. Разбираем, где их проводить и чем платить.

Симптом, с которого начинается этот разговор: в приложении core лежит 40 моделей, views.py на 2000 строк, а любая попытка вынести часть в отдельное приложение упирается в ImportError: cannot import name ... (most likely due to a circular import). Django не запрещает держать всё в одном приложении — фреймворк это терпит годами. Ломается не Django, а команда: два человека не могут работать над соседними фичами без конфликтов в миграциях и в одном файле на тысячи строк.
Статья про то, где проводить границы между приложениями в большом проекте и чем каждая граница оплачивается. Это разбор подходов и их цены, а не пошаговая настройка нового проекта. Ориентируюсь на Django 5.x, но всё сказанное верно и для 4.2 LTS — механика приложений и импорта моделей между версиями не менялась.
Что Django называет приложением и почему это не про папки
Приложение в Django — не просто каталог с файлами. Это единица, которую вы регистрируете в INSTALLED_APPS и которая владеет своими миграциями, моделями, таблицами в базе. Именно миграции делают границу приложения физической, а не косметической: перенести модель из одного приложения в другое — это не git mv, а операция над схемой БД, которую Django отслеживает по метке приложения.
Отсюда первое следствие. Раскладка файлов внутри приложения (services.py, selectors.py, разбиение models на пакет) — вопрос вкуса и договорённостей, менять её можно свободно. А вот граница между приложениями — решение с ценой миграции, и переигрывать его дорого.
Файловую структуру внутри приложения вы меняете за один коммит. Границу между приложениями вы меняете за миграцию, которую придётся аккуратно накатывать на прод.
Признак, что приложение пора делить
Размер файла — плохой критерий. views.py на 2000 строк механически бьётся на пакет views/ без всяких новых приложений. Настоящие признаки границы другие:
- Две подсистемы почти не ссылаются друг на друга через ForeignKey. Если модели
billingиcatalogсвязаны одним-двумя полями, между ними есть естественный шов. - Над частями работают разные люди и постоянно конфликтуют в номерах миграций (
0042,0043от двух авторов в один день). - Часть логики вы бы хотели отключить фиче-флагом или вынести в отдельный сервис через год.
Где проводить границу: три подхода
Подход 1. По доменам
Приложение = бизнес-область: orders, catalog, billing, accounts. Внутри каждого — свои модели, вьюхи, сервисы, шаблоны.
Выигрыш: границы совпадают с тем, как о системе думают люди. Новый разработчик открывает billing и видит там всё про деньги.
Цена: домены редко бывают независимыми. orders ссылается на catalog и на accounts, а billing — на orders. Если catalog в ответ импортирует что-то из orders (например, чтобы посчитать популярность товара), вы получаете цикл. Домены живут, пока зависимости идут в одну сторону.
Подход 2. По слоям
Приложение = технический слой: api, models, services. Встречается в проектах, приехавших из мира, где так принято.
Выигрыш: на старте кажется аккуратным.
Цена: в Django это работает плохо. Одна фича размазывается по всем приложениям, любое изменение трогает три каталога, а миграции всех моделей копятся в одном приложении models — то есть исходная проблема с конфликтами миграций никуда не делась, вы её централизовали. Для Django-проекта слоевое деление на уровне приложений — обычно ошибка. Слои лучше держать внутри доменного приложения файлами.
Подход 3. Один apps-пакет с плоским списком
Все приложения складывают в каталог apps/ и регистрируют как apps.orders, apps.catalog. Это не отдельный подход к границам, а способ не засорять корень проекта. Совмещается с делением по доменам.
Единственная тонкость: путь в INSTALLED_APPS должен совпадать с реальным путём импорта. Если приложение лежит в apps/orders, а вы пишете 'orders' вместо 'apps.orders', Django не найдёт его метку и миграции поедут. В apps.py у такого приложения имя тоже указывается полным путём.
| Подход | Границы совпадают с | Главная цена | Когда брать |
|---|---|---|---|
| По доменам | бизнес-областями | риск циклов между доменами | средний и большой проект с командой |
| По слоям | техническими слоями | фича размазана, миграции централизованы | почти никогда в Django |
| Плоский apps/ | это про раскладку, а не границы | несовпадение пути и метки | как дополнение к доменному |
Главный враг границ — циклический импорт
Циклы возникают там, где два приложения импортируют модели друг друга на уровне модуля. Django загружает models.py всех приложений при старте, и если orders/models.py делает from apps.catalog.models import Product, а catalog/models.py — from apps.orders.models import Order, импорт падает.
Для самих связей ForeignKey это лечится строковой ссылкой — тогда импорт модели вообще не нужен:
# apps/orders/models.py, Django 5.x
from django.db import models
class Order(models.Model):
# Ссылка строкой 'приложение.Модель' — импорт catalog не нужен,
# Django разрешит связь после загрузки всех приложений.
product = models.ForeignKey(
'catalog.Product',
on_delete=models.PROTECT,
related_name='orders',
)
quantity = models.PositiveIntegerField(default=1)Строковая ссылка убирает цикл на уровне полей. Но она не спасает, когда цикл в бизнес-логике: orders/services.py вызывает функцию из billing, а billing/services.py — функцию из orders. Тут строкой не отделаешься, и это уже сигнал, что граница проведена не там.
Правило направления зависимостей
Договоритесь внутри команды, что зависимости между доменами идут в одну сторону, и зафиксируйте это. Обычно так: приложения-«фундаменты» (accounts, catalog) не знают о приложениях-«надстройках» (orders, billing). Надстройка импортирует фундамент, но не наоборот.
Когда фундаменту нужно среагировать на событие из надстройки — не импортируйте её. Используйте сигналы Django (django.db.models.signals) или явную шину событий: надстройка шлёт сигнал, фундамент на него подписан, прямого импорта нет. Проверять направление зависимостей на CI можно инструментом import-linter — он умеет запрещать импорты по слоям и ловить циклы; конфигурация описана в его документации.
Перенос модели между приложениями — где ломается
Допустим, вы решили вынести Invoice из core в новое приложение billing. Наивный путь — вырезать модель, перенести файл, сделать makemigrations — приведёт к тому, что Django сгенерирует DeleteModel в одном приложении и CreateModel в другом. На проде это означает удаление таблицы со всеми данными и создание пустой.
Правильный путь использует операцию, которая меняет только метку приложения, не трогая таблицу:
- В новом приложении создаётся миграция с
SeparateDatabaseAndState: в state-части — перенос модели, в database-части — ничего (таблица уже существует) либоAlterModelTable, если вы решили заодно переименовать таблицу. - Схема таблицы в БД остаётся прежней, меняется только то, какому приложению Django считает модель принадлежащей.
Это тонкая операция, синтаксис которой стоит сверять с разделом миграций в документации Django для вашей версии, а не воспроизводить по памяти. Проверьте её на копии продовой базы прежде, чем катить: цена ошибки — потерянная таблица.
Ещё две вещи, которые ломаются при делении
- Приложение без моделей. Если вы вынесли только вьюхи и сервисы, у приложения нет миграций — это нормально, но убедитесь, что оно попало в
INSTALLED_APPS, иначе его шаблоны иtemplatetagsне будут найдены. - GenericForeignKey поперёк границ. Связь через
contenttypesпрячет зависимость от статического анализа:import-linterеё не увидит, а фактически домены остаются сцепленными. Границу такая связь размывает.
Когда что выбирать
Критерий простой и не про размер кодовой базы, а про людей и связи:
- Проект пишет один-два человека, связей между частями много — не дробите рано. Один-два больших приложения с аккуратной внутренней раскладкой на пакеты дешевле, чем пять приложений с циклами.
- Над проектом работает несколько потоков, части слабо связаны через FK — делите по доменам, фиксируйте направление зависимостей на CI.
- Появилась мысль «этот кусок через год уедет в отдельный сервис» — выделяйте его в приложение уже сейчас, с минимумом FK наружу и общением через сигналы. Позже вынести будет проще.
Что сделать дальше
Начните не с рефакторинга, а с карты зависимостей: постройте граф импортов между приложениями (это умеет import-linter и pydeps) и найдите циклы. Циклы — это места, где границы проведены неверно, и чинить надо в первую очередь их, а не размер файлов. Затем зафиксируйте на CI одно правило: список разрешённых направлений импорта между доменами. Это дешевле любого большого переезда и не даёт структуре деградировать обратно.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Django documentation — Applications, Django documentation — Migration operations (SeparateDatabaseAndState)
Частые вопросы
У меня Django 4.2 LTS, а не 5.x — что-то поменяется?
Сделал makemigrations после переноса модели, а он предлагает DeleteModel и CreateModel — это нормально?
Сколько моделей — это уже повод заводить новое приложение?
Как побороть циклический импорт, если строковая ссылка в FK не помогла?
Стоит ли делить приложения по слоям — models, services, api?
Приложение вынес, но его шаблоны и templatetags перестали находиться — почему?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.