10 min čteníJohnny UnarJohnny Unar

Modulární Django bez cirkusu

Většina týmů nepotřebuje microservices. Potřebuje Django appky s tvrdými hranicemi, verzováním a upgrade cestou, která nerozbije produkci.

špatná odbočka

Spousta týmů bere architekturu jako vynucenou volbu: buď navždy nechat monolit, nebo ho rozřezat na služby, a pak rok vyrábět deployment problémy, které předtím vůbec neměly. Viděl jsem to u úplně normálních SaaS produktů, CRM se zhruba 40 modely, pár Celery workery, zákaznický portál, billing flow, nic exotického, a za šest měsíců ladí gRPC timeouty mezi dvěma službami, které pořád sdílí stejný PostgreSQL cluster a deployují se ze stejného GitHub Actions workflow.

Ten prostor mezi tím se používá žalostně málo. Django už dávno má app systém, namespacy pro migrace, pluggable settings a dost import magie na to, abyste si udělali skutečné hranice mezi moduly, pokud přestanete brát apps/ jako odkladiště všeho možného. Praktický krok je postavit modulární monolit, kde se klíčové domény shipují jako verzované Python balíčky, instalují se jako wheels, propojují se přes explicitní rozhraní a validují se v CI, jako by šlo o cizí dependencies. Stejný process space, stejná transakční hranice, stejný debugger, ale nesrovnatelně lepší disciplína.

Dává to smysl, protože většina produktových týmů neztrácí čas na saturaci CPU nebo síťové škálování. Ztrácí čas na provázanosti změn. Sales sáhne do billing, billing sáhne do accounts, accounts sáhne do notifikací, a pak jeden nevinný rename pole v models.py odpálí půl repozitáře, protože nikdo neví, které importy jsou vlastně bezpečné. Ve Steezru jsme tenhle pattern viděli u interních ERP systémů i backendů na zpracování dokumentů, hlavně v codebasech, které začaly čistě a pak se pod tlakem delivery rozlézaly do stran. Microservices takový codebase nezachrání. Zachrání ho silnější hranice.

Pointa je jednoduchá: zabalte do balíčků domény, které mají mít delší životnost, nechte je běžet in-process, verzujte je bez slitování a upgrade protlačte přes CI. Dostanete většinu engineering výhod, kvůli kterým lidi obvykle sahají po microservices, ale bez service discovery, distributed tracing, bugů s idempotencí a celé té daně, kterou stejně zaplatíte.

zabalte doménu

Nahraditelná Django app musí jít nainstalovat mimo hlavní repo, jinak si na modularitu jen hrajete. Každou důležitější doménu dejte do vlastního Python balíčku s pyproject.toml, builděte wheels přes python -m build, publikujte je do privátního indexu typu GitHub Packages nebo interního devpi a instalujte je do host projektu jako jakoukoliv jinou dependency.

Reálné rozložení balíčku vypadá třeba takhle:

billing-app/ pyproject.toml src/billing_app/__init__.py src/billing_app/apps.py src/billing_app/models.py src/billing_app/api.py src/billing_app/migrations/ tests/

Používejte layout src/. Zabrání náhodným importům z working tree a chyby v packagingu odhalí hned, ne až později. V pyproject.toml držte dependencies explicitně:

toml
1[build-system]
2requires = ["setuptools>=69", "wheel"]
3build-backend = "setuptools.build_meta"
4
5[project]
6name = "billing-app"
7version = "2.4.1"
8requires-python = ">=3.12"
9dependencies = [
10 "Django>=5.0,<5.2",
11 "psycopg[binary]>=3.1,<3.3"
12]
13
14[tool.setuptools.packages.find]
15where = ["src"]

Pak udělejte konfiguraci Django appky nudnou a stabilní:

python
1# src/billing_app/apps.py
2from django.apps import AppConfig
3
4class BillingAppConfig(AppConfig):
5 name = "billing_app"
6 verbose_name = "Billing"
7 default_auto_field = "django.db.models.BigAutoField"

Host projekt:

python
1INSTALLED_APPS = [
2 "django.contrib.auth",
3 "django.contrib.contenttypes",
4 "billing_app.apps.BillingAppConfig",
5]

Zatím nic světoborného. Důležitá je ale psychologie stejně jako technika. Ve chvíli, kdy je kód samostatně verzovaný a publikovaný, lidi přestanou jen tak sahat přes hranice. Začnou cítit cenu změny veřejného rozhraní, a přesně to chcete. Když přejmenování funkce znamená bump verze balíčku a update changelogu, tým najednou mnohem míň bezhlavě vytváří vnitřní coupling.

Veřejné rozhraní držte co nejmenší. Každému balíčku dejte api.py nebo services.py, které se smí importovat zvenku, a zbytek berte jako private. Když jiná appka importuje přímo billing_app.models.Invoice, možná se to ještě dá snést. Když importuje billing_app.tasks._rebuild_invoice_cache, ta hranice už je mrtvá.

importy chtějí disciplínu

Většina pokusů o modularitu v Django selže při importech, ne za runtime. Někdo si v models.py natáhne model z jiné appky, druhá appka importuje zpátky něco do signal handleru, načítání Django app se začne chovat divně a nakonec narazíte na django.core.exceptions.AppRegistryNotReady: Apps aren't loaded yet. nebo, ještě otravnější, na bug s částečnou inicializací, kdy importy lokálně projdou, ale pod Gunicornem spadnou s ImportError: cannot import name 'X' from partially initialized module.

Řešení je přísný směr importů a explicitní integrační body. Doménové balíčky by se neměly navzájem volně importovat. Mají záviset dovnitř na sdílených kontraktech, nebo ven přes tenkou integrační vrstvu, kterou vlastní host projekt. V praxi to znamená třeba používat stringy u cizích klíčů tam, kde musíte překročit hranici appky:

python
1customer = models.ForeignKey("accounts.Account", on_delete=models.PROTECT)

a modely resolvingovat lazy až ve chvíli, kdy je potřebujete:

python
1from django.apps import apps
2
3Account = apps.get_model("accounts", "Account")

apps.get_model nijak nemiluju. Čte se hůř než přímé importy, ale pořád je to lepší než cyklické importy a křehký start aplikace. Používejte ho na hranách, ne všude. Lepší pattern pro komunikaci mimo ORM je definovat úzký protokol v jednom balíčku a implementovat ho v host projektu.

Příklad, billing_app/api.py:

python
1from dataclasses import dataclass
2from decimal import Decimal
3
4@dataclass(frozen=True)
5class ChargeRequest:
6 account_id: int
7 amount: Decimal
8 currency: str
9
10class PaymentGateway:
11 def charge(self, req: ChargeRequest) -> str:
12 raise NotImplementedError

Host pak přes settings zapojí Stripe, Adyen nebo fake gateway:

python
1BILLING_PAYMENT_GATEWAY = "project.payments.StripeGateway"

Uvnitř balíčku to načtěte přes django.utils.module_loading.import_string. Díky tomu zůstane balíček nahraditelný a nevznikají skryté importy do interních částí host projektu. Totéž platí pro signály. Když stačí přímé volání služby nebo explicitní hook, vyhněte se širokým globálním receiverům. Django signály jsou zároveň pohodlné i ledabylé, proto tak dobře šíří coupling a ještě předstírají, že ho snižují.

skutečná hranice jsou migrace

Jestli chcete nezávisle verzované appky, vlastnictví migrací musí být bez debaty. Každý balíček vlastní svůj adresář s migracemi a žádný balíček negeneruje migrace pro modely jiného balíčku. Zní to samozřejmě, porušuje se to pořád.

Django už migrace namespacijuje podle app labelu, což pomáhá, ale dependency hrany pořád rozhodují. Billing app může záviset na accounts.0003_add_status, jasně, ale tyhle vazby držte co nejřidší, protože z upgrade se jinak stane problém nad grafem závislostí. Když support_app závisí na billing migraci, billing závisí na accounts a accounts se rozhodne odkazovat na support kvůli audit metadatům, máte z toho uzel. Rozplétat to při pátečním deployi je za trest.

Mířte na jednosměrné schema dependencies. Sdílené identifikátory jsou lepší než hluboké relační propletence. Někdy to znamená uložit account_id jako integer a validaci řešit přes aplikační službu místo tvrdého foreign key napříč doménami. Puristi to nesnáší. Je mi to jedno. Pokud je hranice domény důležitější než relační čistota, extra lookup je levnější obchod.

V host projektech pinujte migration modules jen tehdy, když pro to máte opravdu dobrý důvod. Defaulty z balíčku většinou stačí:

python
1MIGRATION_MODULES = {
2 # na balíčkové appky nesahejte, pokud neděláte něco nestandardního
3}

Co ale potřebujete určitě, je nacvičený upgrade. Každý release balíčku by se měl testovat proti předchozí vydané verzi a přes skutečný průchod migracemi. V CI si udělejte matici, která nainstaluje billing-app==2.3.0, pustí migrace nad dočasnou instancí PostgreSQL 16, nahraje fixture data, upgraduje na aktuální wheel a znovu spustí python manage.py migrate. Když migrace vybuchne na psycopg.errors.DependentObjectsStillExist: cannot drop column amount because view invoice_summary depends on it, jedině dobře, našli jste to dřív než produkce.

Datové migrace si zaslouží extra nedůvěru. Kde to jde, dělejte je idempotentní, velké updaty kouskujte a nikdy nepředpokládejte, že host databáze je dost malá na jednu obří transakci. Django migration framework dělá šťastnou cestu snadnou a tu nebezpečnou bohužel skoro taky. Seniorní týmy berou migrace jako release engineering, ne jako vygenerovaný boilerplate.

verzujte to pořádně

Nahraditelné appky stojí a padají na disciplíně ve verzování. Jestli je každý release navždy 0.1.x a nikdo neví, co vlastně znamená minor bump, ten balíček je jen složka s papírováním navíc. Používejte semver, víceméně. Major pro breaking change ve veřejném API nebo chování migrací, které vyžaduje zásah operátora, minor pro nové feature, patch pro opravy chyb, které nenutí konzumenty měnit kód.

Potřebujete taky upgrade kontrakt, ne mlhavé týmové folklórní vědění. Každý balíček by měl mít CHANGELOG.md se záznamy typu:

md
1## 3.0.0
2- Removed `billing_app.api.create_invoice`
3- Added `InvoiceService.create()`
4- Migration `0018_backfill_invoice_number` rewrites existing rows, expect longer deploys on large tables
5- Requires host setting `BILLING_PAYMENT_GATEWAY`

Pak to vynucujte v CI. Na podobné věci používáme GitHub Actions, protože je to všudypřítomné a nudné, a to je přesně dobře. Jedno workflow buildne wheel, nainstaluje ho do ukázkového host projektu, pustí contract testy a pak projede upgrade matici přes vybrané starší verze.

Ořezaný job vypadá třeba takhle:

yaml
1jobs:
2 upgrade-test:
3 runs-on: ubuntu-24.04
4 strategy:
5 matrix:
6 from_version: ["2.2.0", "2.3.1", "2.4.0"]
7 services:
8 postgres:
9 image: postgres:16
10 env:
11 POSTGRES_PASSWORD: postgres
12 ports: ["5432:5432"]
13 steps:
14 - uses: actions/checkout@v4
15 - uses: actions/setup-python@v5
16 with:
17 python-version: "3.12"
18 - run: pip install "billing-app==${{ matrix.from_version }}" -r host/requirements.txt
19 - run: cd host && python manage.py migrate && python manage.py loaddata seed.json
20 - run: pip install --force-reinstall dist/billing_app-*.whl
21 - run: cd host && python manage.py migrate && pytest tests/contracts -q

Poslední řádek je důležitý. Contract testy mají ověřovat veřejné chování, na kterém host projekt stojí, ne interní implementaci balíčku. Vytvořit fakturu, propsat platbu, vyemitovat správný shape webhook payloadu, zachovat přechody stavů. Když pustíte jen unit testy samotného balíčku, utečou vám integrační rozbití, která při upgrade bolí nejvíc.

Jakmile tohle týmy zavedou, upgrade přestane působit děsivě. Je z toho rutina, ne archeologie.

kde to přestává fungovat

Tenhle pattern má svoje limity a předstírat opak je přesně způsob, jak se z architektonických článků stává fikce. Pokud různé domény potřebují nezávislé deployment cadence, jiné škálovací charakteristiky nebo odlišné trust boundaries, balíčky uvnitř jednoho Django procesu to nepokryjí. OCR pipeline na dokumenty, která žere CPU v oddělených worker poolech, má prostě jiné provozní potřeby než customer-facing CRUD appka. Tam to podle runtime potřeb klidně rozdělte. Totéž platí pro regulované datové hranice, kde jedna komponenta opravdu nesmí sdílet process memory s jinou.

Většina týmů tam ale není. Mají jeden produkt, jednu databázi, jeden deployment train a codebase, který lepí čím dál víc, protože každá část může sáhnout do každé jiné. Modulární balíčky tenhle konkrétní problém řeší dobře.

Nejtěžší část je kultura. Engineeři milují svobodu přímých importů, dokud nepřijde účet. Budete potřebovat pravidla do code review, linting importů a ochotu zamítat pohodlné zkratky. Nástroje pomůžou. import-linter je dobrý na deklaraci kontraktů typu billing_app nesmí importovat customer_portal. ruff rychle pochytá spoustu nesmyslů. Jednoduchý Architecture Decision Record ve stylu „přístup napříč balíčky jde jen přes api.py“ ušetří nekonečné debaty.

Ještě jedna věc: nebalíčkujte každou appku hned první den. To je cargo cult modularita. Začněte u domén, které se často mění, mají jasného ownera a dávají rozumnou šanci na reuse napříč produkty. Billing je klasický kandidát. Accounts někdy taky. Tenká appka pro marketingový web očividně ne. Ve Steezru stavíme hodně SaaS systémů, většinou v Django a Next.js, a týmy, které zůstávají rychlé i po druhém roce, jsou ty, které zavádějí hranice tam, kde je změna drahá, ne tam, kde architektonický diagram vypadá hezky.

Microservices mají pořád svoje místo. Jen si je nejdřív zaslužte. Wheel soubor a přísná import politika vás dostanou mnohem dál, než většina týmů čeká.

Johnny Unar

Napsal/a

Johnny Unar

Chcete s námi spolupracovat?

Většina týmů nepotřebuje microservices. Potřebuje Django appky s tvrdými hranicemi, verzováním a upgrade cestou, která nerozbije produkci.