Migrating from Django Ninja¶
Existing Ninja routers and ninja-devx controllers can share the same NinjaAPI. Adopt
controllers where repeated resource policy is becoming difficult to maintain; a function
that already expresses its operation clearly does not need to move.
The migration keeps Ninja schemas, auth callables, API exception handlers and Django models. It changes how handlers are grouped and where common policies execute. Ninja's router guide describes the underlying mounting model; controllers produce routers for that same API.
1. Move a function without changing its contract¶
Before:
from django.http import HttpRequest
from ninja import Router, Schema
class HealthOut(Schema):
ok: bool
router = Router()
@router.get("/health", response=HealthOut, operation_id="health_read", auth=None)
def health(request: HttpRequest):
return {"ok": True}
After:
from django.http import HttpRequest
from ninja import NinjaAPI, Schema
from ninja_devx import Controller, get
class HealthOut(Schema):
ok: bool
class HealthController(Controller):
@get("/health", response=HealthOut, operation_id="health_read", auth=None)
def health(self, request: HttpRequest):
return {"ok": True}
api = NinjaAPI()
api.add_router("", HealthController.as_router())
Preserve explicit operation IDs when generated clients use them. The controller's default ID derives from its class/method and may differ from the old function's ID. Keep the old route's methods, slash policy, response status map, auth and throttles explicit during this first step. Do not register both implementations at the same path/method.
2. Move shared configuration after parity tests pass¶
Common auth and permission settings can live in ControllerOptions:
from ninja.security import django_auth
from ninja_devx import Controller, ControllerOptions, IsAuthenticated, get
class ProfileController(Controller):
options = ControllerOptions(auth=django_auth, permissions=[IsAuthenticated()])
@get("/", response=dict[str, str])
def profile(self, request):
return {"username": request.user.get_username()}
Existing API-level auth still applies unless explicitly replaced. auth=None is a public
operation, not “inherit authentication.” Review inherited and route-specific policy before
removing repeated arguments. Authentication is performed by Ninja; ninja-devx permissions
run after validated arguments are available. Consequently malformed and forbidden requests
may be rejected at different stages than an old hand-written check. Test the observed
status and disclosure behavior.
Ninja auth and request-user handling remain separate concepts for custom bearer/API-key
implementations. An arbitrary object returned as request.auth is not automatically a
Django user. Follow the authentication guide
and explicitly establish the principal your permissions expect.
3. Replace repeated CRUD code where it helps¶
from typing import Annotated
from ninja import Schema
from ninja_devx.crud import CRUDController
from pydantic import Field
from notes.models import Note
class NoteIn(Schema):
title: Annotated[str, Field(min_length=1, max_length=200)]
body: str = ""
class NoteOut(Schema):
id: int
title: str
body: str
done: bool
class Notes(CRUDController[Note, NoteOut, NoteIn]):
owner_field = "owner"
scope_queryset_to_owner = True
search_fields = ("title",)
ordering_fields = ("id", "title")
Mount with api.add_router("/notes", Notes.as_router()). The
Quickstart example supplies the corresponding model,
URLconf and tests. Built-in routes are /, /{pk} and their documented methods. Use the
routes mapping to preserve an existing path; compare OpenAPI and URL reverse names after
registration.
The generic controller introduces behavior your old functions may not have had: model validation, generated filters/order validation, configured scope and transactional writes. It also separates input from output. Reuse an existing schema only when it has the intended write permissions; response-only fields should not become writable by convenience.
4. Translate endpoint responsibilities¶
| Function-view responsibility | Controller equivalent | Important boundary |
|---|---|---|
Model.objects.filter(owner=request.user) |
Owner field plus owner queryset scoping | Owner checks alone do not filter lists |
| Fetch, then check a permission | Instance[Model] or get_object |
A custom raw ORM lookup still needs a check |
transaction.atomic(using=...) |
Built-in write scope or explicit atomic service | External HTTP/storage effects are not rolled back |
| Repeated service construction | Constructor or Inject[T] with a registered container |
Select singleton/request/transient lifetime explicitly |
| Error-to-response try/except | ErrorMap / DomainError | Compare old status and body before adopting defaults |
| Model input conversion | Explicit schema plus perform_create/update |
Preserve custom validation, context fields and alias |
| Query optimization | Explicit queryset hints plus schema planner | Arbitrary resolver access cannot be inferred |
| Pagination decorator | pagination_class / options on model controllers |
Preserve page size and envelope if clients depend on them |
| Decorators using function signatures | Operation decorators/plugins | Recheck composition and registration-time introspection |
5. Migrate sync and async independently¶
An existing async function can become an async method without enabling async CRUD globally.
For generic model controllers, choose mode="sync", "async" or the configured default.
Await network I/O; put a transactional group of ORM writes in one sync boundary. Turning a
method into async def does not make a sync service non-blocking.
The Ninja async guide covers the underlying framework model. The ninja-devx async guide adds dependency and transaction patterns. Test async request cancellation, generator cleanup and lazy relation access. Async stream auth/permission/binding preflight happens before headers; an exception raised later by the generator cannot replace an already-sent status.
6. Keep dependency lifetimes visible¶
A request parameter, a request-scoped service and a singleton service have different
owners. Do not move request, a tenant or a database transaction onto self when a
controller can be singleton-scoped. Store per-request data in method arguments, the
invocation context or a request-scoped dependency.
Resource-producing factories must have matching cleanup. Test a successful request, an exception and an async disconnect. Existing module-level client instances can stay in an application-owned singleton container if they are safe to share and closed at shutdown; there is no need to construct every connection on every request.
7. Acceptance and rollout¶
Keep a Ninja TestClient test for every converted route and a real Django WSGI/ASGI test for middleware-sensitive behavior. Compare status/body/headers, auth, 204 bodies, pagination, PATCH omission/null handling, multi-tenant visibility and side effects. Use query assertions for nested schemas and snapshot operation IDs before regenerating clients.
Retain other function routers while one controller is evaluated. The rollback can be a URLconf switch if models and request/response contracts remain compatible. Contrib modules are separate adoption decisions: moving functions to a controller does not require audit, webhooks, uploads or a new DI framework.