Django Backend Engineering
This skill captures a set of engineering standards applied across a family of Django backends. It is not a description of any one repo — it is the set of rules, patterns, and judgment calls that recur across all of them, with the reasoning behind each so you can apply them to new code and new services.
The two house styles
There are two distinct but compatible architectures. Identify which one the service uses before writing code:
| Style | Where | Signature |
|---|---|---|
| Multi-brand factory | the multi-brand platform (and the shared core it builds on) | One codebase serves many portals. Classes resolved through factory(), overridden per portal. Pipeline: Scrubber → Upsert → Publish (serialize → index + cache). Custom ObjectSerializer, not DRF serializers. |
| Layered / repository | the analytics/ingestion, recommendation, traffic-analysis and content-moderation services | Classic layered app: views → services → repository (raw SQL templates) → models. Explicit data/ DTOs, repository/*.py, read-replica routing, DRF or plain Django views. |
Both styles share the same foundations: a Postgres-aware base model, TextField-only
models, Meta-level constraints/indexes, PostgreSQL-native upserts, structlog,
an in-house Celery task wrapper, TimeoutAwareMigration, uv env management, and the
naming/exception conventions below.
When in doubt, match the file you are editing, then match the repo, then fall back to this skill.
Topic map — read the reference for the area you touch
Each reference file explains every practice with why it exists / where it's used / when to use it / tradeoffs / the bad alternative / a production example.
| You are working on… | Read |
|---|---|
| Project layout, apps, the factory/DI pattern, multi-portal scope, feature flags, settings layering, DTOs, service vs repository layers | references/architecture.md |
| API endpoints, DRF views, request lifecycle, responses, pagination, filtering, validation flow | references/api-and-views.md |
| ObjectSerializer, DataScrubber, the scrub→upsert→publish pipeline, N+1 avoidance on context | references/serializers-and-scrubbers.md |
| Models, fields, the Postgres-aware base model, localized/geo fields, migrations, timeouts, concurrent indexes | references/models-and-migrations.md |
ORM queries, upserts/ON CONFLICT, bulk ops, batching, transactions, concurrency, read replicas |
references/queries-database.md |
Authentication, authorization, permission classes, Principal, Keycloak, API keys, service-to-service |
references/auth-and-keycloak.md |
| Celery, the in-house task wrapper, retries, idempotency, routing, RabbitMQ, periodic tasks, background jobs | references/celery-and-jobs.md |
| Caching, Redis, Elasticsearch/hosted search, search indexing, the publishing fan-out | references/caching-and-search.md |
| Logging, structlog, observability, APM/error tracking, exception classes and handlers | references/logging-and-observability.md |
| Naming, code style, type hints, PEP-8 deltas, commit messages, testing | references/code-style-and-testing.md |
Non-negotiable foundations (the short list)
These hold in every service. Everything else is in the references.
- Query the ORM, query little. Select only the fields you need
(
.values_list/.only()), aggressivelyselect_related/prefetch_related, never run a query per item in a loop or serializer. Fall back to raw SQL rarely. - Upserts are native and atomic. Use the PostgreSQL-extensions library's
on_conflict(...)/bulk_upsert, neverget_or_create/update_or_create(racy). - Models: Postgres-aware base model,
TextFieldonly, constraints/indexes inMeta. Nomax_length, no field-levelunique=True/db_index=True, nounique_together. - Migrations touching existing tables set
lock_timeout+statement_timeoutviaTimeoutAwareMigration; indexes go in their own non-atomic concurrent migration. - Views orchestrate; they don't validate or format. Validation lives in scrubbers/serializers, output formatting in serializers. Views are thin.
- Auth is private by default. Protect endpoints with permission classes;
identify the caller via
request.principal; verify a passed user ID matches the token. - Tasks fail loudly and retry. Use the in-house
shared_taskwith explicit dot-name,bind=True,set_logger_context,retry_on_exceptions; no pokemontry/exceptswallowing errors. - Log with
structlogonly — noprint, no stdliblogging. Reuse existing keys; the number of distinct keys is limited downstream (the log-aggregation platform). - Settings are explicit. Define every setting in
common.py; nevergetattr(settings, ...). Prefix platform settingsAPP_, integrations by vendor. - Don't catch
Exception. Catch specific errors (orRuntimeErrorfor a deliberate catch-all); name new exception classes...Error, inheritRuntimeError.
Best Practices (cross-cutting)
- Decide portal scope first. Before editing the shared core, ask "does this apply to
all portals or some?" Shared code affects many sites. Roll out with a feature flag
or a factory override, not a
if portal_name == "brand_a":branch. - Resolve overridable classes through
factory(), never by direct import — a direct import silently bypasses the portal's override. - Thin views, rich pipeline.
scrub → business logic → serialize. Push validation intoDataScrubber, output intoObjectSerializer/DRF serializer. - Batch database work on the collection, not per object. Build one context / one bulk query and index results by id. This is the single most common perf bug.
- Prefer passing objects over IDs between functions to avoid re-querying; use
.exists()not.count(); useupdate(...)/bulk_update/bulk_createover loops. update_fields=on every partial.save(). Cheaper writes, fewer race windows.- Cache read endpoints in
urls.py(not in the view) so the whole cache policy is visible in one place; balance TTL against cache-key cardinality from query params. - Backwards compatibility is a hard constraint. A separate mobile team consumes these payloads — additive changes only on existing serializers/endpoints; confirm breaking changes with the owning dev.
- Type-hint everything; use
@dataclass/StrEnumfor typed, memory-efficient data containers instead of loose dicts. - Localization-safe code. Never hard-code language codes; iterate
settings.LANGUAGES, read defaults fromsettings.LANGUAGE_CODE, use the localized-field query expressions.
Anti-Patterns (reject in review)
- ❌
from core.serializers... import XSerializerfor a portal-overridable class → bypasses the factory. Usefactory().serializers.x. - ❌
if portal_name == "brand_a":string checks → usePortal.current()/ feature flags. - ❌ Validation logic inside a view; manual response-dict construction → scrubbers + serializers.
- ❌
get_or_create/update_or_create→ racy; use nativeon_conflictupserts. - ❌
Model.objects.get(...)in a loop,cached_propertydoing a query on a per-object serializer → N+1; batch on the context. - ❌
except Exception:/ bareexcept:/ try-except swallowing in Celery tasks → hides failures from the error tracker and defeats retries. - ❌
getattr(settings, "X", default)→ silent config drift; define it incommon.py. - ❌
CharField+max_length, field-levelunique=True/db_index=True,unique_together→ useTextField+Meta.constraints/Meta.indexes. - ❌ A migration that alters an existing table with no
lock_timeout/statement_timeout, or anAddIndexin an atomic migration → long locks / downtime. - ❌
print()or stdliblogging; inventing new structlog keys casually. - ❌ New settings for constants that never change per env → declare a module-level constant.
- ❌ New top-level URL folders (
/myendpoint) → nest under/api/; use camelCase paths.
Checklist — before merging a PR
- Portal scope evaluated. If in the shared core, verified impact across multiple portals/brands (or gated behind a flag/factory override).
- Overridable classes resolved via
factory(), not imported directly. - View is thin: validation in scrubber/serializer, output via serializer, no manual dicts.
- Permissions set explicitly; default private; passed user IDs verified against the token.
- No N+1: relations
select_related/prefetch_related; per-collection work batched on context. - Queries select only needed fields;
.exists()over.count(); bulk ops over loops. - Upserts use
on_conflict; noget_or_create/update_or_create. - Writes use
update_fields=; multi-write paths wrapped intransaction.atomic. - Models: Postgres-aware base model,
TextField,Metafirst, constraints/indexes inMeta. - Migrations:
TimeoutAwareMigrationwith timeouts set (orNonefor state-only); indexes in their own non-atomic concurrent migration;poe lint_migrations_fixrun. - Celery tasks: explicit dot-name,
bind=True,set_logger_context, retries configured, no error-swallowing; idempotent /skip_if_runningwhere concurrency matters. - Logging via
structlog, reusing existing keys; noprint/stdlib logging; views don't log routinely. - Exceptions specific (
...Error, inheritRuntimeError); noexcept Exception. - No
getattr(settings, ...); new settings defined incommon.pyand prefixed correctly. - Backwards-compatible payloads; breaking API/serializer changes confirmed with the consumer.
- Tests added (module-level functions,
parametrizewith tuples), run from the correctpytest.inidir. -
poe fix/poe verify(Black, Ruff, mypy) clean.
Checklist — before releasing to production
- Migrations reviewed for lock risk on large/hot tables; timeouts sane; index builds concurrent; migration ordering independent of unmerged code.
- New settings listed in the commit body and configured for every target portal/environment (not just dev).
- Feature gated behind a flag defaulting off; rollout plan is per-portal.
- Caches: TTLs chosen; invalidation/republish path exists for changed serializer output.
- Search index changes have a reindex/republish plan; backfill task identified.
- Celery: queue routing correct; worker memory budget respected (~300mb @ 1x concurrency); retry/backoff bounded; periodic task wired in all 4 required places (see reference).
- Observability: key operations emit structlog events; errors reach the error tracker/APM; no secrets in logs or URLs.
- Backwards compatibility verified against mobile/third-party consumers.
- Rollback path understood (data patches reversible or forward-only-and-safe).
Checklist — when creating a new Django service
-
uvfor env/deps;pyproject.tomlwith Black (line length per repo, 110 default), Ruff (select = ["ALL"]+ explicit ignores), mypy--strict, poe tasks (fix,verify). - Base model = the Postgres-aware base model; adopt TextField/Meta-constraints rules.
- Adopt the in-house migration-safety toolkit's
TimeoutAwareMigration+ custommakemigrations; add the migration linter. - Settings layered
common.py → *_overrides.py → env; explicit settings, nogetattr. -
structlogconfigured (JSON output); define aLoggingEventkey registry if following the layered style. - Auth: decide the
Principal/permission model up front; private-by-default; reuse the in-house SSO wrapper / Keycloak integration rather than rolling your own. - Celery via the in-house task wrapper; define routing keys and a periodic-task registry.
- Choose the architecture style (factory-multiportal vs layered-repository) and lay out
folders accordingly (
views/ serializers/ scrubbers/ tasks/ tools/orviews/ services/ repository/ data/). - DRF configured with a default auth/permission class, a standard exception handler, and a pagination class.
-
pytest+--reuse-db, data factories/collections,CODEOWNERS, CI quality gate. -
CLAUDE.md/ guidelines file documenting the above so the conventions are discoverable.