Skip to content

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:

  1. 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.
  2. We have empirical evidence. The demo/wagtail-cms draft 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 raw body.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_fields on models.

Considered Options

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-spectacular plus 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 the ViewSet/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-spectacular and 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-spectacular output drifts from hand-written views, giving Flutter an inaccurate contract. → Mitigation: use drf-spectacular (the only actively maintained DRF OpenAPI 3.x generator; DRF's built-in schema support is deprecated, and drf-yasg is locked to OpenAPI 2.0). Concrete guardrails:
    • Generate openapi.yaml in 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 where drf-spectacular needs the most help (unlike ModelViewSet, FBVs don't auto-infer serializers).
    • For polymorphic or union responses (e.g. mixed content-type feeds), use PolymorphicProxySerializer or explicit @extend_schema(responses=…) overrides — don't rely on auto-detection, which produces oneOf schemas that confuse some code generators.
    • Flutter team can auto-generate a typed Dart client from the committed schema (e.g. openapi_generator pub package), catching breaking changes at compile time.
  • 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 Ranker interface (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.
  • 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-cms draft branch — empirical evidence: ran both frameworks; Ninja's raw get_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.