dj-scaffold

Set up a Django project into the op-django layout so the architecture, signals, and settings skills have a foundation to build on. Use when starting a new project from scratch, or when converting an existing Django project to follow this opinionated structure. Creates the src/project/ shell (ids, services registry, api, reliable signals), installs dependencies with uv, and establishes the per-app directory conventions.

ashbrener/opinionated-django10 installsMITSynced Aug 26

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: dj-scaffold
description: Set up a Django project into the op-django layout so the architecture, signals, and settings skills have a foundation to build on. Use when starting a new project from scratch, or when converting an existing Django project to follow this opinionated structure. Creates the src/project/ shell (ids, services registry, api, reliable signals), installs dependencies with uv, and establishes the per-app directory conventions.
license: MIT
---

# Scaffold an op-django Project

You are preparing a Django project to use the op-django patterns. After this skill runs, the `dj-architecture` and `dj-signals` skills can add features on top without any further setup.

## BEFORE WRITING CODE

Figure out which situation you're in:

- **Greenfield** — no Django project exists yet. You will run `uv init` and `django-admin startproject`, then transform the result.
- **Existing Django project** — a `manage.py`, `settings.py`, and at least one app already exist. You will add the `src/project/` shell alongside what's there and relocate files only if asked.

Read `pyproject.toml` (if present) and locate `manage.py` and `settings.py` so you know the project's current layout. Confirm with the user before moving any existing files.

## Target Layout

```
src/
  manage.py
  project/
    __init__.py
    settings.py
    urls.py
    wsgi.py
    asgi.py
    api/
      __init__.py   # NinjaAPI() instance, exception handlers, mounts all resource routers
      <resource>/
        __init__.py  # re-exports router
        routes.py    # handler functions
        schemas.py   # ninja.Schema input types
    types.py        # AuthedRequest and other shared typing aliases
    ids.py          # prefixed ULID generators
    services.py    # svcs registry + get() helper
    signals.py     # ReliableSignal base + send_reliable machinery
  <app>/
    __init__.py
    apps.py
    admin.py
    models/
      __init__.py
      <entity>.py
    dtos/
      __init__.py
      <entity>.py
    repositories/
      __init__.py
      <entity>.py
    services/
      __init__.py
      <entity>.py
    signals.py      # optional, defines ReliableSignal instances
    receivers.py    # optional, @receiver handlers — must be idempotent
tests/
  <app>/
    test_repo.py
    test_service.py
    test_api.py
pyproject.toml
```

Per-app `models/`, `dtos/`, `repositories/`, `services/` are **packages**, not single files — one module per entity.

## Step 1: Dependencies

Use `uv` for everything. Never `pip` or `poetry`.

```bash
uv add 'django>=6.0' 'django-ninja>=1.6' 'pydantic>=2.0' 'svcs>=25.1' \
       'python-ulid>=3.0' 'celery>=5.4' python-decouple dj-database-url \
       'whitenoise>=6.7' 'gunicorn>=23.0' 'uvicorn[standard]>=0.30'
uv add --dev ruff 'pyrefly>=0.42' django-stubs pytest pytest-django
```

Gunicorn is the process manager; `uvicorn[standard]` provides the ASGI worker class used in Step 9. Both are runtime deps — the project serves ASGI by default, so they belong in the main group.

`whitenoise` is a runtime dep, not a dev extra. Django's contrib-staticfiles middleware only serves admin assets under `manage.py runserver`; any other runtime (gunicorn, uvicorn, gunicorn+uvicorn workers) renders the admin unstyled without an external static-file server. Whitenoise restores admin styling in every runtime without requiring nginx. The middleware + `STORAGES` wiring lives in the `dj-settings` skill.

Pyrefly auto-recognizes Django constructs as long as `django-stubs` is installed — no plugin, no `mypy_django_plugin`-style config. See [pyrefly.org/en/docs/django](https://pyrefly.org/en/docs/django/) for the current support matrix.

## Step 2: `src/project/ids.py`

```python
from ulid import ULID


def prefixed_ulid(prefix: str) -> str:
    return f"{prefix}_{str(ULID()).lower()}"


def _make_generator(prefix: str):
    def generate() -> str:
        return prefixed_ulid(prefix)

    generate.__name__ = f"generate_{prefix}_id"
    generate.__qualname__ = f"generate_{prefix}_id"
    return generate


# Add one generator per aggregate root, with a unique 3-4 char prefix.
# Example:
# generate_prd_id = _make_generator("prd")
```

## Step 3: `src/project/services.py`

```python
import svcs

registry = svcs.Registry()

# Register repositories and services here as the project grows.
# Example:
# from products.repositories.product import ProductRepository
# from products.services.product import ProductService
#
# registry.register_factory(ProductRepository, ProductRepository)
#
# def _product_service_factory(container: svcs.Container) -> ProductService:
#     return ProductService(container.get(ProductRepository))
#
# registry.register_factory(ProductService, _product_service_factory)


def get[T](service_type: type[T]) -> T:
    """Get a service from the registry. Works anywhere — views, tasks, commands."""
    return svcs.Container(registry).get(service_type)
```

## Step 4a: `src/project/types.py`

Narrows `request.user` to a guaranteed-authenticated Django `User` so handlers don't have to deal with `AnonymousUser` unions.

```python
from django.contrib.auth.models import User
from django.http import HttpRequest


class AuthedRequest(HttpRequest):
    """
    An HttpRequest whose `user` attribute is guaranteed to be an authenticated User.

    Use as the first-argument annotation on any django-ninja handler that requires
    auth. The narrowing is a contract, not runtime enforcement — pair this with
    ninja's `auth=` on the router or a middleware that rejects anonymous requests.
    """
    user: User  # type: ignore[assignment]
```

## Step 4b: `src/project/api/` package

The API lives in a package, not a single file. `src/project/api/__init__.py` owns the `NinjaAPI()` instance and central exception handlers, and mounts one router per resource subpackage. Each resource subpackage (`src/project/api/<resource>/`) contains `routes.py` (handler functions), `schemas.py` (ninja `Schema` input types), and an `__init__.py` that re-exports the router.

`src/project/api/__init__.py`:

```python
from ninja import NinjaAPI

# Import resource routers and mount them below.
# from project.api.products import router as products_router

api = NinjaAPI()

# api.add_router("/products", products_router)


@api.exception_handler(ValueError)
def on_value_error(request, exc: ValueError):
    return api.create_response(request, {"detail": str(exc)}, status=400)


@api.exception_handler(LookupError)
def on_lookup_error(request, exc: LookupError):
    return api.create_response(request, {"detail": str(exc)}, status=404)


@api.exception_handler(PermissionError)
def on_permission_error(request, exc: PermissionError):
    return api.create_response(request, {"detail": str(exc)}, status=403)
```

Example resource subpackage — `src/project/api/products/routes.py`:

```python
from typing import List

from ninja import Router

from products.dtos.product import ProductDTO
from products.services.product import ProductService
from project.services import get
from project.types import AuthedRequest

from .schemas import CreateProductIn

router = Router()


@router.get("/", response=List[ProductDTO])
def list_products(request: AuthedRequest):
    return get(ProductService).list_products()
```

`src/project/api/products/schemas.py`:

```python
from decimal import Decimal

from ninja import Schema


class CreateProductIn(Schema):
    name: str
    price: Decimal
    stock: int
```

`src/project/api/products/__init__.py`:

```python
from .routes import router

__all__ = ["router"]
```

To add a new resource router: (a) create `src/project/api/<resource>/` with `routes.py`, `schemas.py`, and `__init__.py`, then (b) import and mount the router in `src/project/api/__init__.py` via `api.add_router("/<resource>", <resource>_router)`.

Wire `api.urls` into `src/project/urls.py`:

```python
from django.contrib import admin
from django.urls import path
from project.api import api

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", api.urls),
]
```

## Step 5: `src/project/signals.py` — Reliable Signals

This module provides the `ReliableSignal` base that apps import. Receivers run asynchronously via Celery, and `send_reliable()` enqueues them inside the current DB transaction so rollbacks are respected.

```python
import json

from celery import shared_task
from django.db import transaction
from django.dispatch import Signal
from django.utils.module_loading import import_string


@shared_task
def _dispatch_reliable_receiver(receiver_path: str, kwargs_json: str) -> None:
    receiver = import_string(receiver_path)
    receiver(**json.loads(kwargs_json))


class ReliableSignal(Signal):
    """A Django Signal whose receivers run asynchronously via Celery.

    - `send_reliable()` must be called inside a `transaction.atomic()` block.
    - Receiver tasks are enqueued on transaction commit, so rollbacks are respected.
    - Delivery is at-least-once. Every receiver MUST be idempotent.
    - Arguments MUST be JSON-serializable (pass IDs, never model instances).
    """

    def send_reliable(self, sender, **kwargs) -> None:
        payload = json.dumps(kwargs)
        for _, receiver in self._live_receivers(sender):
            path = f"{receiver.__module__}.{receiver.__qualname__}"
            transaction.on_commit(
                lambda p=path: _dispatch_reliable_receiver.delay(p, payload)
            )
```

This is a minimal implementation — feel free to harden it (dead-letter queue, replay tooling, explicit retry policy) as the project matures.

## Step 6: Celery

Create `src/project/celery.py`:

```python
import os

from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")

app = Celery("project")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
```

In `src/project/__init__.py`:

```python
from .celery import app as celery_app

__all__ = ("celery_app",)
```

## Step 7: Settings

Hand off to the **dj-settings** skill to lay out `settings.py` with banner sections. At minimum it must include:

- `INSTALLED_APPS` with each project app as `"<app>.apps.<App>Config"`
- `CELERY_BROKER_URL` and `CELERY_RESULT_BACKEND` (read via `python-decouple`)
- `DEFAULT_AUTO_FIELD` is irrelevant — all PKs are ULID `CharField`s

## Step 8: Tooling config in `pyproject.toml`

```toml
[tool.ruff]
line-length = 100
target-version = "py312"

[tool.pyrefly]
project-includes = ["src"]
python-version = "3.12"

[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "project.settings"
python_files = ["test_*.py"]
pythonpath = ["src"]
```

**Pyrefly + Django caveats** (from [pyrefly.org/en/docs/django](https://pyrefly.org/en/docs/django/)):

- Pyrefly has **built-in** Django support. Install `django-stubs` and it just works — no plugin to enable, no extra `[tool.pyrefly]` keys required.
- **Reverse relations are not yet supported.** Accessing `user.order_set` (the implicit reverse manager Django generates from a `ForeignKey`) will flag as an attribute error. Work around it in the repository layer by either (a) querying the child model directly — `OrderRepository().list_for_user(user_id)` — or (b) using an explicit `related_name` and a narrow `cast` / `# type: ignore[attr-defined]` at the call site. Do not paper over this in services or DTOs; push it down to the repo.
- **`ManyRelatedManager` is generic over `[Parent, Model]`** rather than the concrete child type (unlike mypy's django-plugin). For DTO coercion this doesn't matter — the `coerce_related_manager` validator handles it — but don't rely on pyrefly to catch mistyped M2M targets.
- Django's `QuerySet` typing beyond `.all()` is still thin. Keep chained queryset expressions inside the repository where you can annotate the return type as `list[SomeDTO]` and let the caller rely on that.
- Pyrefly's Django support is **actively evolving**; re-check the docs when upgrading pyrefly and remove workarounds as they become unnecessary.

## Step 9: Server Runtime

Serve the project as ASGI by default. Django supports async views, middleware, and ORM calls, and django-ninja runs comfortably on ASGI — running WSGI now forecloses async work later for no benefit. The standard production invocation is gunicorn supervising uvicorn workers:

```bash
uv run gunicorn project.asgi:application \
    -k uvicorn.workers.UvicornWorker \
    -w 3 \
    --bind 0.0.0.0:8000
```

- `project.asgi:application` is the callable already created by `django-admin startproject` in `src/project/asgi.py` — no edits needed.
- `-k uvicorn.workers.UvicornWorker` swaps gunicorn's default sync worker for an ASGI-capable one.
- `-w 3` is a sensible starting point; tune to `(2 * CPU) + 1` for the target host.
- For local development, `uv run uvicorn project.asgi:application --reload` is a lighter alternative to `manage.py runserver` once async views are in play. `runserver` itself still works — it routes through the ASGI handler when an async view is hit.

`src/project/wsgi.py` stays in place for tooling that expects it (some PaaS health probes, legacy management commands), but no entrypoint should target it.

## Step 10: Local dev stack (`docker-compose.yml`)

Postgres and Redis run as containers for local development. The application reads its connection strings from the env contract owned by the `dj-settings` skill (`DATABASE_URL`, `REDIS_URL`); no other dev-stack assumption travels into the Python code.

Write `docker-compose.yml` at the project root.

**Invariant: top-level `name: <project_slug>`.** Compose's default project name is derived from the directory the file lives in. When two unrelated projects each nest their backend under a directory called `backend/`, a bare `docker compose up` from either claims the same `backend` namespace — containers `backend-postgres-1`, volume `backend_postgres_data`, network `backend_default`. Whichever stack starts last writes into the shared Postgres data directory. Setting `name:` at the top level pins the Compose project namespace regardless of directory; this field is Compose v2.4+ and is non-optional.

Replace `<project_slug>` with the kebab-case repo name (the same slug used as `name` in `pyproject.toml`).

```yaml
name: <project_slug>

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: <project_slug>
      POSTGRES_USER: <project_slug>
      POSTGRES_PASSWORD: <project_slug>
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U <project_slug>"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  postgres_data:
  redis_data:
```

The `<project_slug>` substitution appears in four places — the top-level `name:`, the three `POSTGRES_*` env vars, and the `pg_isready -U` healthcheck. All four must agree.

Remap host ports only when the host already runs a conflicting service on 5432 / 6379; keep the container-side default so the in-container service config doesn't need to know about the remap.

## Step 11: Verify

Populate `STATIC_ROOT` so whitenoise has something to serve, then run the verification gate:

```bash
uv run python src/manage.py collectstatic --noinput
uv run python src/manage.py check
uv run ruff check src
uv run ruff format --check src
uv run pyrefly check src
uv run pytest
```

All six must pass. Fix any issue rather than silencing it. `collectstatic` is part of the gate (not a one-time setup step) because admin asset breakage is a regression class that only surfaces when statics are actually gathered.

## COMPLETION CHECKLIST

- [ ] Dependencies added via `uv add` (including `whitenoise>=6.7`, `gunicorn`, and `uvicorn[standard]`)
- [ ] `src/project/ids.py` with `_make_generator` helper
- [ ] `src/project/services.py` with `registry` and `get()`
- [ ] `src/project/types.py` with `AuthedRequest`
- [ ] `src/project/api/__init__.py` with `NinjaAPI` instance (per-resource routers live in `src/project/api/<resource>/` subpackages)
- [ ] Central exception handlers registered (`ValueError` → 400, `LookupError` → 404, `PermissionError` → 403)
- [ ] `src/project/signals.py` with `ReliableSignal` base
- [ ] `src/project/celery.py` + `__init__.py` export
- [ ] `urls.py` mounts `api.urls`
- [ ] Settings organized via the `dj-settings` skill (including `ASGI_APPLICATION`, `WhiteNoiseMiddleware` after `SecurityMiddleware`, and `STORAGES["staticfiles"]` set to `CompressedStaticFilesStorage`)
- [ ] `pyproject.toml` has ruff / pyrefly / pytest config
- [ ] Server runtime documented: gunicorn + `uvicorn.workers.UvicornWorker` against `project.asgi:application`
- [ ] `docker-compose.yml` at project root with top-level `name: <project_slug>` pinning the Compose namespace, plus postgres + redis services with healthchecks
- [ ] `collectstatic`, `django check`, ruff, pyrefly, pytest all pass

Once this checklist is complete, the `dj-architecture` and `dj-signals` skills can build features on top without any extra setup.

More Backend Frameworks skills

← All Backend Frameworks skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY