Migrating from Django Ninja Extra¶
Both projects provide class-based organization for Ninja APIs, but their controller,
permission and dependency interfaces differ. There is no drop-in adapter. Keep the existing
Extra API at its current prefix while a plain NinjaAPI serves the migrated resources at
a temporary prefix.
The upstream controller documentation and model-controller guide are the reference for the old interface. Confirm your installed version's configuration; application subclasses may add behavior beyond these examples.
1. Inventory inherited behavior¶
Read the controller's decorators, base classes, ModelConfig, model schema settings,
permission classes and Injector modules. Record generated routes as well as methods you
wrote. Inspect the resulting OpenAPI and real requests rather than assuming two similarly
named controllers generate the same contract.
Pay attention to controller discovery, automatic model schemas, route enable/disable configuration, authentication imports, permission messages, pagination decorators and custom services. These are the main sources of differences during a port.
2. Convert one explicit route¶
A small Extra controller:
from ninja import Schema
from ninja_extra import NinjaExtraAPI, api_controller, route
class StatusOut(Schema):
status: str
@api_controller("/status")
class StatusController:
@route.get("/", response=StatusOut, operation_id="status_read")
def read(self):
return {"status": "ok"}
api = NinjaExtraAPI()
api.register_controllers(StatusController)
The corresponding ninja-devx controller:
from django.http import HttpRequest
from ninja import NinjaAPI, Schema
from ninja_devx import Controller, get
class StatusOut(Schema):
status: str
class StatusController(Controller):
@get("/", response=StatusOut, operation_id="status_read")
def read(self, request: HttpRequest):
return {"status": "ok"}
api = NinjaAPI(urls_namespace="migrated-status")
api.add_router("/status", StatusController.as_router())
The prefix moves from the controller decorator to mounting. The explicit operation ID
avoids an incidental generated-client rename. Request state is a parameter, not
self.context.request. A class definition does not register itself; mount every controller
explicitly or through mount.
3. Translate model configuration explicitly¶
| Extra configuration/concept | ninja-devx surface | Work required |
|---|---|---|
ModelControllerBase with ModelConfig |
CRUDController[Model, Out, In] or individual mixins |
List every generated operation and its response/status |
| Model schema configuration | Explicit Ninja schemas or opt-in AutoCRUDController | Review read/write fields, required values, nulls and constraints |
| Controller prefix/decorator | API/router mounting | Preserve URL namespaces, slashes and tags |
| Route decorators | get/post/put/patch/delete |
Preserve response map, auth, explicit IDs and custom options |
| Controller context request | Method request argument | Remove per-request mutation of shared instances |
| Object lookup helper | get_object(request, pk) / Instance[Model] |
Preserve queryset restriction and object checks |
| Searching/ordering decorators | search_fields, ordering_fields, FilterSchema |
Match parameter names, supported lookups and invalid values |
| Pagination decorator | pagination_class and pagination_options |
Match response envelope, count and page limits |
| Injector provider/module | Container factory or dishka/svcs adapter | Port scope and cleanup, not just constructor annotations |
| Extra permission class | ninja-devx BasePermission implementation | Reimplement request/object methods against the new interface |
| Custom model service | service_class or perform_* hook |
Preserve transactions, validation and request-controlled fields |
Do not replace a ModelConfig with AutoCRUDController[Model] until its generated input and
output match the old field policy. Explicit schemas are usually easier to review during
the migration. The CRUD guide lists defaults and override points.
4. Port permissions and scope¶
Extra permission objects are not interchangeable with ninja-devx permissions. Translate business rules and exercise anonymous, authenticated, wrong-owner and wrong-tenant cases. In ninja-devx, permission instances are shared configuration; they must not store the current request or object. Sync and async checks have documented method pairs.
For an owner-controlled resource:
from ninja_devx.crud import CRUDController
# Model and schemas are defined by the application.
class Documents(CRUDController[Document, DocumentOut, DocumentIn]):
owner_field = "owner"
scope_queryset_to_owner = True
tenant_field = "workspace"
This configuration assumes an authorized workspace resolver. It does not make an arbitrary
X-Workspace value trustworthy. Owner/object checks and list visibility are different
policies. Optional ObjectPermissions can filter by view grants; built-in writes then
recheck the persisted result before commit.
A route's permission override can replace inherited permissions. Use Also(...) for an
intentional addition, and test composed request/object checks. Existing services that
construct objects with another owner or tenant must be corrected; disabling response
refresh does not disable the scope check.
5. Port Injector bindings by lifetime¶
Suppose the application depends on a shared configuration object and a request-scoped service. Register those lifetimes explicitly:
from ninja_devx import Container, Controller, get
class Settings:
pass
class ReportService:
def __init__(self, settings: Settings):
self.settings = settings
class Reports(Controller):
def __init__(self, service: ReportService):
self.service = service
@get("/", response=dict[str, bool])
def read(self, request):
return {"available": self.service is not None}
container = Container()
container.singleton(Settings)
container.scoped(ReportService)
router = Reports.as_router(container=container)
The application still owns container shutdown. Resource factories should yield and close resources through the documented lifecycle. Override providers in tests rather than replacing global objects. Do not keep a request-scoped dependency in a singleton controller; startup checks and lifecycle tests should cover that combination. See dependency injection.
6. Check persistence and HTTP differences¶
Default model writes validate the model and execute on the controller's selected write alias. Custom ModelRepository instances with conflicting aliases are rejected. A service that writes to a separate store remains responsible for consistency; no transaction spans an HTTP API and a relational database automatically.
Ninja schemas can often be reused, but audit their resolvers and aliases. Invalid input, ordering, domain errors and model validation may produce different error codes or shapes. Compare list envelopes and async pagination behavior. Preserve explicit operation IDs until client regeneration is planned.
Async handlers do not require an immediate whole-controller conversion. Move them with existing behavior first, then adopt generic async variants or DI cleanup separately. Async preflight uses a narrow Ninja operation integration; supported version tests are still required. Neither a plain router nor a class-based API can guarantee immunity to upstream framework changes.
7. Remove the old integration after parity¶
Run accepted/rejected request fixtures against both prefixes, including middleware and session/CSRF behavior through Django's HTTP handlers. Verify custom action permissions, query counts, transactions, cancellation and dependency teardown. Confirm generated client and OpenAPI diffs with the consuming application.
When the migrated prefix is accepted, move its routes to the intended public paths and
remove duplicate controller registration. Remove ninja_extra from INSTALLED_APPS and the
old Injector wiring only when no remaining routes use them. Keep both implementations
available during rollback if their database and side-effect contracts remain compatible.
The plain Ninja migration guide covers coexistence with remaining function
routers; DRF migration covers serializer-heavy ports.