ADR-0004: Mobile API Framework — Django Ninja vs DRF (with Wagtail)¶
Status¶
Accepted (2026-07-03) — resolves the API-framework question reopened in ADR-0001 (app-stack note, 2026-07-01: "Django Ninja vs DRF … No new ADR until the choice is made").
Date¶
2026-07-02
Context¶
The backend is Wagtail-on-Django (ADR-0001). We now need the API layer that feeds the Flutter app. ADR-0001 originally paired Django with Django Ninja (Pydantic v2, native OpenAPI), then reopened that half on 2026-07-01 for one reason: Wagtail ships its own content API (wagtail.api.v2), and it is built on Django REST Framework (DRF) — so a Wagtail-first backend may pay less integration cost with DRF than it gains from Ninja's ergonomics. django-ninja was removed from dependencies to avoid pre-biasing the evaluation.
Two facts narrow the decision:
- Auth no longer couples to the API framework. ADR-0003 put identity on Firebase (the backend only validates a token); the old
django-allauth-headless-chained-to-Ninja coupling is gone. Either framework validates a Firebase JWT with a few lines. - We have empirical evidence. The
demo/wagtail-cmsdraft branch (reference only, not merged) ran both frameworks at once: Wagtail's DRF-based/api/v2/for generic page/content delivery, and Django Ninja at/api/passages/…for curated mobile-shaped endpoints (today's passage, related-passage ranking). Its Ninja StreamField serialization was a rawbody.get_prep_value()dump — a workaround for the fact that Ninja has no native Wagtail content serialization. That impedance mismatch is the crux of this decision.
The mobile surface this must serve is mostly hand-shaped, not generic page listing: Home carousels, the Journey reading experience (chapters → mandatory/supporting pieces → keystones, per the Journey Reading Experience Spec, Notion 3914dccd9d8b80dfb74bd2216de5ab65), tag-overlap "related content" ranking, source ladder, ambient reflection counts. But every one of those shapes still embeds Wagtail-authored content — StreamField bodies, image renditions, rich text — that must serialize faithfully.
Decision Drivers¶
- Must: faithfully serialize Wagtail content (StreamField blocks, image renditions, rich text) to the app; produce a typed OpenAPI contract the Flutter team consumes; serve custom, composed mobile endpoints (not just raw pages); stay one process/one deployable (ADR-0001).
- Should: minimise the number of web-API frameworks and serialization idioms in the codebase; stay compatible with the Wagtail ecosystem (headless preview, future Wagtail packages); keep custom-endpoint boilerplate low; leave async on the table for read hot paths.
- Nice-to-have: match the team lead's FastAPI/Ninja familiarity; reuse Wagtail's declarative
api_fieldson models.
Considered Options¶
Option A: DRF + Wagtail (single framework) — recommended¶
Use DRF throughout: Wagtail's wagtail.api.v2 for content delivery, plus custom function-based DRF views (@api_view) for the composed mobile endpoints, with business logic in a thin reusable service layer (see Application style below). drf-spectacular generates the OpenAPI 3 schema for Flutter.
- Pros:
- Wagtail is DRF — StreamField, image renditions, and rich text serialize for free via declarative
api_fields; no hand-rolled content dumps (the exact thing the demo branch got wrong). - One framework, one serialization mental model, one request/response idiom across content and custom endpoints.
- Ecosystem-aligned: headless preview and most third-party Wagtail packages assume DRF.
- DRF is production-hardened for a decade; deep community and hiring pool.
- DRF serializers are usable purely as a validation/serialization boundary, which fits the thin-layers + services style we want (below).
- Cons:
- OpenAPI is not native — needs
drf-spectacularplus occasional annotations to produce a clean typed contract (Ninja gives this zero-config). This is the main cost of choosing DRF. - DRF's defaults nudge toward fat class-based
ViewSets and logic-in-serializers; we deliberately opt out (see Application style). - Async is second-class in DRF (sync-first); async read paths would sit outside DRF views.
Option B: Django Ninja + Wagtail (Ninja primary)¶
Ninja for all app endpoints; hand-roll Wagtail content serialization (StreamField/renditions) as reusable helpers. Don't expose wagtail.api.v2.
- Pros:
- Pydantic v2 ergonomics, native async, native zero-config OpenAPI — cleanest possible typed contract.
- Function-first by nature; low boilerplate for the custom, composed shapes that dominate this app.
- Matches the team lead's FastAPI/Ninja strength.
- Cons:
- Throws away Wagtail's content API. StreamField block serialization, image renditions, and rich-text expansion must be reimplemented and kept in sync with Wagtail upgrades — the demo's
get_prep_value()dump shows how easily this degrades to a leaky raw payload. - Diverges from the Wagtail ecosystem (headless preview, Wagtail packages expect DRF).
- Pydantic-over-Django-ORM adds a mapping layer Wagtail already solves in DRF.
Option C: Hybrid — Wagtail v2 (DRF) for content + Ninja for curated endpoints¶
What the demo branch actually did: run both.
- Pros: each tool at its strength — Wagtail v2 serializes content natively; Ninja shapes curated responses cleanly.
- Cons: two web-API frameworks in one process — two routing systems, two auth integrations, two error-format conventions, and two OpenAPI documents the Flutter team must stitch into one contract. Doubles the maintenance and onboarding surface for a small team on a short runway. The split contract undercuts the "typed contract from day one" driver rather than serving it.
Decision¶
We will use Option A: DRF + Wagtail.
The deciding factor is that this is a Wagtail-first, content-heavy product on a small team / long-lived client build. Almost every mobile response embeds Wagtail-authored content, and Wagtail serializes that content natively only through DRF. Choosing DRF keeps the codebase to one framework and one serialization idiom, inherits StreamField/rendition handling for free, and stays inside the Wagtail ecosystem — at the price of adding drf-spectacular for the OpenAPI contract, which is a mature, well-supported dependency.
Django Ninja (Option B) is the close runner-up and the better tool for greenfield custom APIs — but here it forces us to re-implement and maintain Wagtail content serialization, which is ongoing risk the demo already surfaced. The hybrid (Option C) is explicitly rejected: running both frameworks doubles the API surface and fragments the very typed contract we are trying to give Flutter.
Application style (how we apply DRF)¶
DRF is adopted as a thin HTTP boundary, not an application framework. This deliberately opts out of DRF's fatter defaults:
- Serializers do validation and (de)serialization only — no business logic, no queries, no side effects. They are the shape of the wire, nothing more (a Pydantic-style boundary).
- Models stay thin — persistence and simple derived properties, not orchestration or workflow logic.
- Views are function-based (
@api_view/ plain function views) — small, readable, and explicit. Avoid theViewSet/router indirection where a function is clearer. - Business logic lives in small, reusable services — plain Python functions/classes composed per use case (e.g. the
Ranker, related-content ranking, Home composition, Journey reading assembly). Views call services; services own the logic and are unit-testable without HTTP. - This aligns with the Wireup DI choice in ADR-0001: services are the injected units; views and serializers stay dependency-light.
The intent: logic is reused across HTTP endpoints, Wagtail hooks, Celery tasks, and management commands without being trapped inside serializer/view classes.
Runtime baseline & sync-first¶
- Django 5.2 LTS (stable, per ADR-0001) — pin the LTS line for a long-lived client build; not the 6.x feature release.
- Views are synchronous for V1. DRF is sync-first, and sync views on Django 5.2 are sufficient at MVP scale — this is an accepted, deliberate choice, not a limitation we're working around. Moving specific read hot paths to async (async views + async ORM) is a later optimisation, taken only if telemetry shows it's needed. Not required now.
Consequences¶
Positive¶
- Content endpoints inherit faithful StreamField/rendition/rich-text serialization from Wagtail — no bespoke content-dump code to maintain across Wagtail upgrades.
- One framework, one idiom: lower onboarding cost, one error format, one auth integration, one OpenAPI document.
- Ecosystem-compatible with Wagtail headless preview and future Wagtail packages.
- Thin serializers/models + service layer keep business logic reusable and testable off the HTTP path, and make the
Ranker(and similar) swap-points clean.
Negative¶
- Lose Ninja's zero-config OpenAPI and Pydantic ergonomics; add
drf-spectacularand some schema annotations to reach a clean Flutter contract. - Function-based views + service layer is a convention we must hold against DRF's ViewSet-centric grain — needs light guardrails in review so logic doesn't creep back into serializers/views.
- Sync-first views on Django 5.2 LTS: we accept synchronous views for V1 (see Runtime baseline). If a read hot path later needs async, it must be handled outside DRF views (async Django view + async ORM) — a deliberate later optimisation, not a V1 requirement.
Risks & Mitigations¶
- Risk:
drf-spectacularoutput drifts from hand-written views, giving Flutter an inaccurate contract. → Mitigation: usedrf-spectacular(the only actively maintained DRF OpenAPI 3.x generator; DRF's built-in schema support is deprecated, anddrf-yasgis locked to OpenAPI 2.0). Concrete guardrails:- Generate
openapi.yamlin CI (./manage.py spectacular --file …); commit the schema; diff it on every API PR so reviewers see contract changes explicitly. - Fail CI on undocumented or untyped endpoints (
--fail-on-warn). - Use
@extend_schema()on function-based views to keep request/response types explicit — this is wheredrf-spectacularneeds the most help (unlikeModelViewSet, FBVs don't auto-infer serializers). - For polymorphic or union responses (e.g. mixed content-type feeds), use
PolymorphicProxySerializeror explicit@extend_schema(responses=…)overrides — don't rely on auto-detection, which producesoneOfschemas that confuse some code generators. - Flutter team can auto-generate a typed Dart client from the committed schema (e.g.
openapi_generatorpub package), catching breaking changes at compile time.
- Generate
- Risk: DRF's defaults pull logic back into serializers/ViewSets over time. → Mitigation: the Application-style rules above are a review checklist item; keep a thin
services/layer as the canonical home for logic; serializers reviewed for "shape only". - Risk: a genuinely async, high-fan-out read path (e.g. graph traversal at scale) later fits DRF poorly. → Mitigation: sync is fine for V1; the
Rankerinterface (ADR-0001) already isolates ranking, so if telemetry later warrants async, an endpoint can become a plain Django async view calling the same service — without changing the framework decision.
Related¶
- ADR-0001 — Backend Architecture; reopened this exact choice, favoured Ninja for native OpenAPI, and chose Wireup DI (the service layer this ADR's application style builds on).
- ADR-0003 — Firebase auth; decoupled the auth layer from the API framework, so this decision is auth-agnostic.
demo/wagtail-cmsdraft branch — empirical evidence: ran both frameworks; Ninja's rawget_prep_value()StreamField dump motivates preferring Wagtail-native (DRF) serialization.docs/design/cms-architecture.md— the content model these endpoints serialize.- Journey Reading Experience Spec — Notion
3914dccd9d8b80dfb74bd2216de5ab65— a primary consumer shape (chapters, mandatory reading, keystones); see also the internal Journey-spec / demo-divergence notes.