Implement a Django feature following the opinionated architecture — prefixed ULID IDs, repository pattern, Pydantic DTOs, svcs service locator, project-scoped django-ninja API, Celery reliable signals, and layered tests. Use when the user asks to add a new entity, endpoint, app, or business logic in a Django project that follows these conventions.
Implement a Django feature following the opinionated architecture — prefixed ULID IDs, repository pattern, Pydantic DTOs, svcs service locator, project-scoped django-ninja API, Celery reliable signals, and layered tests. Use when the user asks to add a new entity, endpoint, app, or business logic in a Django project that follows these conventions.
allowed-tools
Read, Write, Edit, Bash, Grep, Glob
Implement a Django Feature
You are implementing a feature in an opinionated, fully type-safe Django project managed with uv. Every convention below is mandatory. Do not deviate.
Why this architecture exists: Django's ORM is powerful but hard to type — querysets, model instances, related managers, and F()/Q() expressions don't play well with static type checkers. This project solves that by pushing all ORM usage into repositories that return Pydantic DTOs. Services receive repos via constructor injection and contain pure business logic with zero ORM imports. Views are thin dispatchers. The result: everything from the repository boundary outward is fully typed, IDE-friendly, and testable in isolation.
Tooling:uv is the package manager. All commands use uv run. Never use pip, poetry, or raw python — always uv run python, uv run pytest, etc. To add a dependency: uv add <package>.
BEFORE WRITING CODE
Gather current project state by reading:
src/project/ids.py — existing ID prefixes (must be unique)
For Django RelatedManager fields (reverse FKs, M2M), add the coercion validator:
@field_validator("children", mode="before")@classmethoddefcoerce_related_manager(cls, v):
ifhasattr(v, "all"):
returnlist(v.all())
return v
Layer 4: Repository
File: src/<app>/repositories/<entity>.py
RULES:
ORM objects NEVER leave this layer — every public method returns a DTO or list[DTO]
Convert with MyEntityDTO.model_validate(orm_obj)
prefetch_related() when the DTO has nested relations
@transaction.atomic on any method with multiple writes
One repo per aggregate root — child entities are managed by the parent's repo
All ID params are str
Layer 5: Service
File: src/<app>/services/<entity>.py
RULES:
Receives repos via __init__ — NEVER instantiates them, NEVER imports models
Contains all business logic: validation, orchestration, cross-repo coordination
Touches ZERO ORM — no .objects, no F(), no Q(), no model imports
Returns DTOs
Layer 6: Register in svcs
Add to src/project/services.py:
from myapp.repositories.my_entity import MyEntityRepository
from myapp.services.my_entity import MyEntityService
# Register the repository
registry.register_factory(MyEntityRepository, MyEntityRepository)
# Register the service with a factory that pulls its repo dependenciesdef_my_entity_service_factory(container: svcs.Container) -> MyEntityService:
repo = container.get(MyEntityRepository)
return MyEntityService(repo)
registry.register_factory(MyEntityService, _my_entity_service_factory)
Both repos and services get registered. Service factories wire up repo dependencies via the container. The get() helper at the bottom of the file makes them available anywhere.
Layer 7: API Routes
Routes live in a per-resource package under src/project/api/ — NOT in app directories. Each resource is its own subpackage:
Input schemas are ninja.Schema classes defined in schemas.py next to the routes
Output schemas reuse the DTOs from the app — do not duplicate
The resource package's __init__.py re-exports the router from routes (e.g. from .routes import router)
The top-level src/project/api/__init__.py creates the NinjaAPI() instance, registers exception handlers, and mounts each resource router with api.add_router("/my-entities", my_entities_router)
Pattern: from project.services import get, then get(MyEntityService) to obtain a wired service
Every handler's first arg is typed request: AuthedRequest — never untyped. AuthedRequest lives in src/project/types.py and narrows request.user to an authenticated Django User
ID path params are str
Handlers do NOT try/except — errors bubble up to the central exception handler (see below)
src/project/api/my_entities/routes.py:
from typing importListfrom ninja import Router, Status
from myapp.dtos.my_entity import MyEntityDTO
from myapp.services.my_entity import MyEntityService
from project.services import get
from project.types import AuthedRequest
from .schemas import CreateMyEntityIn
router = Router()
@router.get("/", response=List[MyEntityDTO])deflist_entities(request: AuthedRequest):
return get(MyEntityService).list_entities()
@router.post("/", response={201: MyEntityDTO})defcreate_entity(request: AuthedRequest, payload: CreateMyEntityIn):
return Status(201, get(MyEntityService).create_entity(name=payload.name))
@router.get("/{entity_id}/", response=MyEntityDTO)defget_entity(request: AuthedRequest, entity_id: str):
return get(MyEntityService).get_entity(entity_id)
Central exception handlers
Services raise plain Python exceptions — ValueError for bad input, LookupError for missing records, PermissionError for forbidden access. They do NOT know about HTTP. The mapping happens once, centrally, in src/project/api/__init__.py:
test_service.py — Mock the repos, validate business logic
Services are tested WITHOUT a database. Mock the repository. This is the most important test layer — it proves your business logic is correct independently of Django.
from unittest.mock import MagicMock
deftest_create_delegates_to_repo():
repo = MagicMock()
expected = MyEntityDTO(id="xxx_fake", name="Test", ...)
repo.create.return_value = expected
service = MyEntityService(repo)
result = service.create_entity(name="Test", ...)
assert result == expected
repo.create.assert_called_once_with(name="Test", ...)
deftest_business_rule_rejects_bad_input():
repo = MagicMock()
# configure mock to trigger the rule
service = MyEntityService(repo)
with pytest.raises(ValueError, match="..."):
service.do_something_invalid(...)
When a business operation needs to trigger async side-effects (notifications, cache invalidation, analytics), use reliable signals — NOT standard Django signals.
Signal Definition
File: src/<app>/signals.py
from project.signals import ReliableSignal
my_event = ReliableSignal()
Sending
Call send_reliable()inside a transaction.atomic() block in the service layer. Arguments MUST be JSON-serializable — pass entity IDs, never model instances:
# In the service methoddefcreate_entity(self, name: str) -> MyEntityDTO:
with transaction.atomic():
entity = self.repo.create(name=name)
my_event.send_reliable(sender=None, entity_id=entity.id)
return entity
Receivers
File: src/<app>/receivers.py
Register with @receiver. Load receivers in apps.py → ready().
CRITICAL: Every receiver MUST be idempotent. The system guarantees at-least-once delivery, not exactly-once. A receiver may run more than once for the same event. Design accordingly:
Check if the action was already performed before performing it
Use database constraints or flags to prevent duplicate effects
Never assume a receiver runs exactly once
from django.dispatch import receiver
from .signals import my_event
@receiver(my_event)defon_my_event(obj_id: str, **kwargs):
# Idempotent: guard against duplicate executionif already_processed(obj_id):
return
do_work(obj_id)
apps.py
classMyAppConfig(AppConfig):
defready(self):
from . import receivers # noqa: F401
Testing Receivers
Test receivers in isolation. Mock external dependencies. Verify idempotency by calling the receiver twice with the same arguments:
deftest_receiver_is_idempotent():
on_my_event(obj_id="xxx_fake")
on_my_event(obj_id="xxx_fake") # second call must be safe# assert side-effect happened exactly once
RULES
NEVER use standard send() for post-commit side-effects — use send_reliable()
Arguments MUST be JSON-serializable (strings, numbers, booleans)
Receivers MUST be idempotent — this is non-negotiable
Receivers MUST NOT import or touch ORM models directly — use a repository if DB access is needed
VERIFY
Run all four checks. ALL must pass before you report done.
uv run ruff check src
uv run ruff format --check src
uv run pyrefly check src
uv run pytest
If anything fails, fix it and re-run.
COMPLETION CHECKLIST
Before reporting done, confirm every item:
ID generator in src/project/ids.py with unique 3-4 char prefix
Model: Meta first (verbose names + indexes), __prefix__ ClassVar, CharField PK with ULID default, zero logic
DTO: str IDs, from_attributes=True, RelatedManager coercion if needed
Repository: returns DTOs only, model_validate(), @transaction.atomic for multi-writes
Service: repos via __init__, zero ORM, business logic only
Repo and service registered in src/project/services.py
Routes in src/project/api/<resource>/routes.py, schemas in schemas.py, mounted in src/project/api/__init__.py using from project.services import get
request: AuthedRequest annotation on every handler
Central exception handlers registered in src/project/api/__init__.py (ValueError→400, LookupError→404, PermissionError→403)
Admin registered per models skill conventions (list_display, list_per_page = 25, search_fields, readonly_fields, ordering, raw_id_fields/autocomplete_fields for large FKs, inlines with extra = 0)
App in INSTALLED_APPS (if new) using dotted AppConfig path
Migrations generated and applied
test_repo.py: real DB, asserts ID prefix
test_service.py: mocked repos, tests business logic
test_api.py: HTTP integration, asserts status codes + response shape
Signals in src/<app>/signals.py if async side-effects needed
Receivers in src/<app>/receivers.py — idempotent, loaded in ready()
ruff check, ruff format --check, pyrefly check, pytest all pass