Backend Engineering · Python · Django REST Framework

Django Clean Architecture DRF · Celery · Channels · PostgreSQL · Redis

Batteries-included, yet disciplined. A field guide to building Django backends that scale beyond the monolith — with clean separation, async tasks, real-time WebSocket, and airtight security.

DRF + ViewSets Celery async tasks Django Channels WS JWT + RBAC AES-256 fields Custom managers Service layer Repository pattern Signals Rate limiting
1
Views / Serializers
DRF ViewSets · APIViews · permissions · throttling
HTTP
↓ calls ↓
2
Service Layer
Business logic · orchestration · no HTTP/ORM concerns
Logic
↓ calls ↓
3
Repository / Selectors
Queryset logic · custom managers · cache · no business rules
Data
↓ reads/writes ↓
4
Models
Django ORM · abstract bases · validators · signals
ORM
↓ async tasks via ↓
5
Celery + Channels
Background tasks · WebSocket · scheduled jobs
Async

01 — Architecture

The Layer Contract

Django's "fat models, thin views" advice doesn't scale. The solution: thin views, thin models, and a dedicated service layer that owns business logic.

The core rule: Views handle HTTP only — authentication, request parsing, response formatting. Services handle business logic — they are plain Python classes with no Django HTTP imports. Repositories/Selectors handle ORM queries. Models are schema + validators only, no business logic methods.
V
Views & Serializers
DRF ViewSets and APIViews. Validate input via serializers, call one service method, return response. Zero queryset calls in views. Permissions and throttling classes applied here.
S
Service Layer
Plain Python classes. All business logic lives here — password hashing, token generation, pricing rules, notification dispatch. Services call repositories. Never call request.user or HttpResponse.
R
Selectors / Repositories
All ORM queries centralised in selector functions or repository classes. Views and services never write raw queryset logic. Selectors are pure — no side effects, no saves, no business rules.
M
Models
Django ORM models with abstract base classes for audit fields (created_at, updated_at, uuid pk). Validators in clean(). Signals for cross-cutting concerns. No business logic methods on models.
C
Celery + Channels
Celery workers handle async tasks — emails, report generation, third-party API calls. Django Channels provides WebSocket via ASGI. Both use the same service layer — no duplication.
I
Infrastructure
Settings split by environment (base/dev/staging/prod). Django-environ for typed env vars. Gunicorn + Nginx for HTTP, Daphne/Uvicorn for ASGI. Sentry for error tracking. Structured logging via structlog.

02 — Project Structure

App-First Organisation

Each Django app is a vertical slice — models, views, services, selectors, and serializers all together by feature. No horizontal layering across the whole project.

project_root /
config/— Django project package
settings/
base.py— shared settings
development.py
production.py
urls.py— root URL conf, includes app routers
asgi.py— ASGI app with Channels routing
celery.py— Celery app instance
apps/
common/— shared across all apps
models.py— TimeStampedModel, UUIDModel abstract bases
exceptions.py— AppException hierarchy
pagination.py
permissions.py— IsOwner, IsAdmin DRF permissions
crypto.py— AES-256 field-level encryption
users/— repeat this pattern per feature
models.pyModel
serializers.pyDRF
views.pyView
services.pyService— business logic
selectors.pySelector— ORM queries only
urls.py
admin.py
signals.py
tasks.pyCelery
tests/Tests
test_views.py
test_services.py
factories.py— factory_boy
chat/— WebSocket feature
consumers.pyConsumer— Django Channels
routing.py— WebSocket URL routing
services.py
infrastructure/
cache.py— Redis cache decorators + helpers
logging.py— structlog configuration
storage.py— S3 / GCS via django-storages
requirements/
base.txt
production.txt
pyproject.toml
docker-compose.yml

03 — Code Patterns

Python Blueprints

Production-ready Python. Every snippet wires cleanly into the layer above and below it — no fat views, no fat models.

common/models.py — abstract basesmodel
import uuid
from django.db import models


class TimeStampedModel(models.Model):
    """Adds created_at / updated_at to every model."""
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class UUIDModel(models.Model):
    """UUID primary key — never expose sequential int IDs."""
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False,
    )

    class Meta:
        abstract = True


class BaseModel(UUIDModel, TimeStampedModel):
    """Combine both — use this as the base for all app models."""
    class Meta:
        abstract = True
users/models.py — custom user modelmodel
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db import models
from apps.common.models import BaseModel
from apps.common.crypto import EncryptedCharField
from .managers import UserManager


class User(BaseModel, AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True, db_index=True)
    name  = models.CharField(max_length=255)
    phone = EncryptedCharField(max_length=500, blank=True)
    role  = models.CharField(
        max_length=20,
        choices=[('admin', 'Admin'), ('user', 'User'),
                 ('moderator', 'Moderator')],
        default='user',
    )
    is_active = models.BooleanField(default=True)
    is_staff  = models.BooleanField(default=False)

    objects = UserManager()

    USERNAME_FIELD  = 'email'
    REQUIRED_FIELDS = ['name']

    class Meta:
        db_table = 'users'
        indexes  = [models.Index(fields=['email', 'role'])]
users/managers.py — custom managermodel
from django.contrib.auth.models import BaseUserManager


class UserManager(BaseUserManager):
    def create_user(self, email, password=None, **extra):
        if not email:
            raise ValueError('Email is required')
        email = self.normalize_email(email)
        user  = self.model(email=email, **extra)
        user.set_password(password)   # Argon2 via PASSWORD_HASHERS
        user.save(using=self._db)
        return user

    def create_superuser(self, email, password=None, **extra):
        extra.setdefault('is_staff', True)
        extra.setdefault('is_superuser', True)
        extra.setdefault('role', 'admin')
        return self.create_user(email, password, **extra)

    # Custom querysets on the manager
    def active(self):
        return self.filter(is_active=True)

    def by_role(self, role: str):
        return self.active().filter(role=role)
users/serializers.py — DRFview
from rest_framework import serializers
from .models import User


class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model  = User
        fields = ['id', 'email', 'name', 'role', 'created_at']
        read_only_fields = ['id', 'created_at']


class RegisterSerializer(serializers.Serializer):
    email    = serializers.EmailField()
    name     = serializers.CharField(max_length=255)
    password = serializers.CharField(
        min_length=8, write_only=True,
        style={'input_type': 'password'},
    )

    def validate_email(self, value):
        if User.objects.filter(email=value).exists():
            raise serializers.ValidationError('Email already registered.')
        return value.lower()


class LoginSerializer(serializers.Serializer):
    email    = serializers.EmailField()
    password = serializers.CharField(write_only=True)
users/views.py — thin ViewSetsview
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .services import UserService
from .serializers import RegisterSerializer, UserSerializer


class AuthViewSet(viewsets.ViewSet):
    """Thin view — call service, return response. Nothing else."""

    @action(detail=False, methods=['post'])
    def register(self, request):
        serializer = RegisterSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        user = UserService.register(**serializer.validated_data)
        return Response(
            UserSerializer(user).data,
            status=status.HTTP_201_CREATED,
        )

    @action(detail=False, methods=['post'])
    def login(self, request):
        serializer = LoginSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        tokens = UserService.login(**serializer.validated_data)
        return Response(tokens)

    @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
    def me(self, request):
        return Response(UserSerializer(request.user).data)
users/urls.py — DRF Routerview
from rest_framework.routers import DefaultRouter
from .views import AuthViewSet, UserViewSet

router = DefaultRouter()
router.register(r'auth',  AuthViewSet,  basename='auth')
router.register(r'users', UserViewSet, basename='user')

urlpatterns = router.urls

# Generates routes:
# POST /auth/register/
# POST /auth/login/
# GET  /auth/me/
# GET  /users/          (list — admin only)
# GET  /users/{id}/     (retrieve)
# PUT  /users/{id}/     (update)
# DELETE /users/{id}/   (destroy)
users/services.py — business logicservice
from django.db import transaction
from apps.common.exceptions import AuthenticationError, ConflictError
from .models import User
from .selectors import UserSelector
from .tasks import send_welcome_email
from infrastructure.token import TokenService


class UserService:
    """Pure business logic. No request/response objects here."""

    @staticmethod
    @transaction.atomic
    def register(email: str, name: str, password: str) -> User:
        if UserSelector.email_exists(email):
            raise ConflictError('Email already registered.')

        user = User.objects.create_user(
            email=email, name=name, password=password,
        )
        # Fire async Celery task — non-blocking
        send_welcome_email.delay(str(user.id))
        return user

    @staticmethod
    def login(email: str, password: str) -> dict:
        user = UserSelector.by_email(email)
        if not user or not user.check_password(password):
            raise AuthenticationError('Invalid credentials.')
        return TokenService.generate_pair(user)

    @staticmethod
    @transaction.atomic
    def deactivate(user_id: str, actor: User) -> None:
        if str(actor.id) == user_id:
            raise ValidationError('Cannot deactivate yourself.')
        User.objects.filter(id=user_id).update(is_active=False)
users/selectors.py — ORM queriesselector
from django.db.models import QuerySet
from .models import User
from infrastructure.cache import cache_result


class UserSelector:
    """All ORM reads in one place. No saves. No business logic."""

    @staticmethod
    def by_email(email: str) -> User | None:
        return (User.objects
                .filter(email=email.lower(), is_active=True)
                .select_related()
                .first())

    @staticmethod
    @cache_result(ttl=300, key_prefix='user')
    def by_id(user_id: str) -> User | None:
        return User.objects.filter(id=user_id).first()

    @staticmethod
    def email_exists(email: str) -> bool:
        return User.objects.filter(email=email.lower()).exists()

    @staticmethod
    def list_active(
        search: str | None = None,
        role:   str | None = None,
    ) -> QuerySet[User]:
        qs = User.objects.active().order_by('-created_at')
        if search:
            qs = qs.filter(name__icontains=search)
        if role:
            qs = qs.filter(role=role)
        return qs
common/exceptions.py — typed errorsdomain
from rest_framework.exceptions import APIException
from rest_framework import status


class AppException(APIException):
    """Base — DRF handles HTTP serialisation automatically."""
    status_code = status.HTTP_400_BAD_REQUEST
    default_code = 'error'


class AuthenticationError(AppException):
    status_code = status.HTTP_401_UNAUTHORIZED
    default_code = 'authentication_failed'


class ForbiddenError(AppException):
    status_code = status.HTTP_403_FORBIDDEN
    default_code = 'forbidden'


class NotFoundError(AppException):
    status_code = status.HTTP_404_NOT_FOUND
    default_code = 'not_found'


class ConflictError(AppException):
    status_code = status.HTTP_409_CONFLICT
    default_code = 'conflict'
infrastructure/token.py — JWT serviceinfra
from datetime import timedelta
import jwt
from django.conf import settings
from django.utils import timezone


class TokenService:

    @staticmethod
    def generate_pair(user) -> dict:
        now = timezone.now()
        access_payload = {
            'sub':    str(user.id),
            'email': user.email,
            'role':  user.role,
            'type':  'access',
            'iat':   now,
            'exp':   now + timedelta(minutes=15),
        }
        refresh_payload = {
            'sub':  str(user.id),
            'type': 'refresh',
            'iat':  now,
            'exp':  now + timedelta(days=7),
        }
        return {
            'access':  jwt.encode(access_payload,  settings.JWT_SECRET, algorithm='HS256'),
            'refresh': jwt.encode(refresh_payload, settings.JWT_SECRET, algorithm='HS256'),
        }

    @staticmethod
    def decode(token: str) -> dict:
        return jwt.decode(
            token, settings.JWT_SECRET,
            algorithms=['HS256'],
        )
common/authentication.py — DRF backendauth
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
import jwt
from django.conf import settings
from apps.users.models import User
from apps.users.selectors import UserSelector


class JWTAuthentication(BaseAuthentication):

    def authenticate(self, request):
        header = request.META.get('HTTP_AUTHORIZATION', '')
        if not header.startswith('Bearer '):
            return None

        token = header.split(' ', 1)[1]
        try:
            payload = jwt.decode(
                token, settings.JWT_SECRET,
                algorithms=['HS256'],
            )
        except jwt.ExpiredSignatureError:
            raise AuthenticationFailed('Token expired.')
        except jwt.InvalidTokenError:
            raise AuthenticationFailed('Invalid token.')

        user = UserSelector.by_id(payload['sub'])
        if not user or not user.is_active:
            raise AuthenticationFailed('User not found.')
        return user, payload
common/permissions.py — RBACauth · rbac
from rest_framework.permissions import BasePermission


class IsAdminUser(BasePermission):
    def has_permission(self, request, view) -> bool:
        return (
            request.user.is_authenticated
            and request.user.role == 'admin'
        )


class IsOwnerOrAdmin(BasePermission):
    """Object-level — user can only access their own resources."""
    def has_object_permission(self, request, view, obj) -> bool:
        if request.user.role == 'admin':
            return True
        owner_id = getattr(obj, 'user_id', getattr(obj, 'id', None))
        return str(owner_id) == str(request.user.id)


# Usage in ViewSet:
class UserViewSet(viewsets.ModelViewSet):
    permission_classes = [IsOwnerOrAdmin]

    def get_permissions(self):
        if self.action == 'list':
            return [IsAdminUser()]
        return super().get_permissions()
common/crypto.py — AES-256 model fieldinfra · security
from cryptography.fernet import Fernet
from django.conf import settings
from django.db import models
import base64, os

# Fernet = AES-128-CBC + HMAC-SHA256 (industry standard for field encryption)
def _get_fernet() -> Fernet:
    key = base64.urlsafe_b64encode(
        settings.FIELD_ENCRYPTION_KEY[:32].encode().ljust(32, b'\0')
    )
    return Fernet(key)


class EncryptedCharField(models.CharField):
    """Transparently encrypts value before save, decrypts on load."""

    def from_db_value(self, value, expression, connection):
        if value is None:
            return value
        try:
            return _get_fernet().decrypt(value.encode()).decode()
        except Exception:
            return value

    def get_prep_value(self, value):
        if not value:
            return value
        return _get_fernet().encrypt(value.encode()).decode()


# settings.py — PASSWORD_HASHERS
PASSWORD_HASHERS = [
    'django.contrib.auth.hashers.Argon2PasswordHasher',
    'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
]
common/throttling.py — rate limitinginfra
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle


class LoginThrottle(AnonRateThrottle):
    scope = 'login'      # settings: REST_FRAMEWORK.DEFAULT_THROTTLE_RATES
    rate  = '10/minute'  # 10 attempts per minute per IP


class BurstUserThrottle(UserRateThrottle):
    scope = 'burst'
    rate  = '60/minute'


class SustainedUserThrottle(UserRateThrottle):
    scope = 'sustained'
    rate  = '1000/day'

# settings/base.py
REST_FRAMEWORK = {
    'DEFAULT_THROTTLE_CLASSES': [
        'apps.common.throttling.BurstUserThrottle',
        'apps.common.throttling.SustainedUserThrottle',
    ],
    'DEFAULT_THROTTLE_RATES': {
        'burst':     '60/min',
        'sustained': '1000/day',
        'login':     '10/min',
    },
}
config/celery.py — app setupcelery
import os
from celery import Celery

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')

app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

# settings/base.py — Celery config
CELERY_BROKER_URL      = env('REDIS_URL')
CELERY_RESULT_BACKEND  = env('REDIS_URL')
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT  = ['json']
CELERY_TIMEZONE        = 'UTC'
CELERY_TASK_ALWAYS_EAGER = DEBUG  # run inline in tests

# Beat schedule for periodic tasks
CELERY_BEAT_SCHEDULE = {
    'clean-expired-tokens': {
        'task':     'apps.users.tasks.clean_expired_tokens',
        'schedule': 3600,  # every hour
    },
}
users/tasks.py — Celery taskscelery
from celery import shared_task
from celery.utils.log import get_task_logger

logger = get_task_logger(__name__)


@shared_task(
    bind=True,
    max_retries=3,
    default_retry_delay=60,  # 60 second retry delay
    autoretry_for=(Exception,),
    acks_late=True,           # only ack after success
)
def send_welcome_email(self, user_id: str) -> None:
    from apps.users.selectors import UserSelector
    from infrastructure.email import EmailService

    user = UserSelector.by_id(user_id)
    if not user:
        logger.warning(f'User {user_id} not found for welcome email')
        return

    try:
        EmailService.send_welcome(user)
        logger.info(f'Welcome email sent to {user.email}')
    except Exception as exc:
        logger.error(f'Email failed: {exc}')
        raise self.retry(exc=exc)


@shared_task
def clean_expired_tokens() -> int:
    from django.utils import timezone
    from apps.users.models import RefreshToken
    deleted, _ = RefreshToken.objects.filter(
        expires_at__lt=timezone.now()
    ).delete()
    logger.info(f'Cleaned {deleted} expired tokens')
    return deleted
config/asgi.py — ASGI + Channelsasgi
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from channels.security.websocket import AllowedHostsOriginValidator
from apps.chat.routing import websocket_urlpatterns

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')

django_asgi = get_asgi_application()

application = ProtocolTypeRouter({
    'http':      django_asgi,
    'websocket': AllowedHostsOriginValidator(
        AuthMiddlewareStack(           # attaches request.user
            URLRouter(websocket_urlpatterns)
        )
    ),
})
chat/consumers.py — WebSocket consumerchannels
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from apps.common.exceptions import AuthenticationError
from infrastructure.token import TokenService


class ChatConsumer(AsyncWebsocketConsumer):

    async def connect(self):
        # Validate JWT from query string
        token = self.scope['query_string'].decode().split('token=')[-1]
        try:
            payload   = TokenService.decode(token)
            self.user_id = payload['sub']
        except Exception:
            await self.close(code=4001)
            return

        self.room = self.scope['url_route']['kwargs']['room_name']
        self.group = f'chat_{self.room}'

        await self.channel_layer.group_add(self.group, self.channel_name)
        await self.accept()

    async def disconnect(self, code):
        await self.channel_layer.group_discard(self.group, self.channel_name)

    async def receive(self, text_data):
        data = json.loads(text_data)
        await self.channel_layer.group_send(
            self.group, {
                'type':    'chat.message',
                'message': data.get('message'),
                'from':    self.user_id,
            }
        )

    async def chat_message(self, event):
        await self.send(text_data=json.dumps({
            'message': event['message'],
            'from':    event['from'],
        }))
chat/routing.py + settingschannels
from django.urls import re_path
from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer.as_asgi()),
]

# settings/base.py
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG':  {
            'hosts': [env('REDIS_URL')],
            'capacity':     1500,
            'expiry':       10,
        },
    },
}

# Pushing events from a service/task to WebSocket clients:
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer

channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
    f'chat_{room}', {
        'type':    'chat.message',
        'message': text,
        'from':    'system',
    }
)
settings/base.py — structured configinfra
import environ

env = environ.Env(
    DEBUG=(bool, False),
    ALLOWED_HOSTS=(list, []),
)
environ.Env.read_env()  # reads .env file

SECRET_KEY    = env('SECRET_KEY')
DEBUG         = env('DEBUG')
ALLOWED_HOSTS = env('ALLOWED_HOSTS')

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'rest_framework',
    'corsheaders',
    'channels',
    'django_filters',
    'apps.common',
    'apps.users',
    'apps.chat',
]

DATABASES = {'default': env.db('DATABASE_URL')}
CACHES    = {'default': env.cache('REDIS_URL')}

AUTH_USER_MODEL  = 'users.User'
JWT_SECRET       = env('JWT_SECRET')
FIELD_ENCRYPTION_KEY = env('FIELD_ENCRYPTION_KEY')

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'apps.common.authentication.JWTAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_PAGINATION_CLASS':
        'apps.common.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
    'EXCEPTION_HANDLER':
        'apps.common.exceptions.custom_exception_handler',
}
infrastructure/cache.py — decoratorinfra
import functools, json
from django.core.cache import cache


def cache_result(ttl: int = 300, key_prefix: str = ''):
    """Decorator that caches the return value in Redis."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            key = f'{key_prefix}:{":".join(str(a) for a in args)}'
            hit = cache.get(key)
            if hit is not None:
                return hit
            result = func(*args, **kwargs)
            cache.set(key, result, timeout=ttl)
            return result
        return wrapper
    return decorator


def invalidate_cache(key_prefix: str, *args) -> None:
    key = f'{key_prefix}:{":".join(str(a) for a in args)}'
    cache.delete(key)

# Usage in selectors.py:
@cache_result(ttl=300, key_prefix='user')
def by_id(user_id: str) -> User | None:
    return User.objects.filter(id=user_id).first()
tests/factories.py — factory_boytest
import factory
from factory.django import DjangoModelFactory
from apps.users.models import User


class UserFactory(DjangoModelFactory):
    class Meta:
        model = User

    email = factory.Sequence(lambda n: f'user{n}@example.com')
    name  = factory.Faker('name')
    role  = 'user'

    @classmethod
    def _create(cls, model_class, *args, **kwargs):
        return model_class.objects.create_user(
            password='testpassword123', **kwargs
        )


class AdminFactory(UserFactory):
    role     = 'admin'
    is_staff = True


# test_services.py — fast, no HTTP
def test_register_creates_user(db):
    user = UserService.register(
        email='new@example.com', name='New', password='pass1234!'
    )
    assert user.email == 'new@example.com'
    assert user.check_password('pass1234!')

04 — Data Flows

Every Request Traced

From HTTP wire to PostgreSQL row — every middleware, permission check, and cache hit in order.

Authenticated REST request

HTTP request
Django middleware
JWTAuthentication
DRF permission check
Throttle check
ViewSet action

View → Service → Selector → DB

View validates input
Service.method()
Selector.query()
Redis cache check
→ miss →
Django ORM → PG

Registration → Celery task

POST /auth/register/
UserService.register()
User.objects.create_user()
send_welcome_email.delay()
Celery worker → email

WebSocket message broadcast

ws.receive()
JWT validate
channel_layer.group_send()
Redis pub/sub
all group consumers → ws.send()

Service → push WS event (e.g. from Celery task)

Celery task completes
async_to_sync(channel_layer.group_send)
Redis → consumer → client WS

Error cascade — AppException → DRF response

Service raises ConflictError
DRF exception handler
custom_exception_handler()
{ detail, code } JSON 409

05 — Feature Matrix

Every Requirement Covered

All requirements mapped to Django's idiomatic solutions — using the framework's batteries where they shine, and bypassing them where they don't.

🏗️
Service layer pattern
Plain Python classes with @staticmethod or @classmethod methods. No Django HTTP imports. All business logic here — validates business rules, orchestrates repositories, dispatches tasks.
Pure Python — zero deps
🔍
Selectors / repository pattern
All ORM queries isolated in selector classes. Services never write querysets. Selectors are pure read functions — no saves, no side effects. Enables easy mocking in unit tests.
Django ORM — built-in
🔐
JWT authentication
Custom DRF authentication backend — no SimpleJWT dependency. Access token (15m) + refresh token (7d). JWTAuthentication class reads Authorization header, decodes, fetches user via selector.
PyJWT ^2.x
🛡️
RBAC permissions
Custom DRF BasePermission classes — IsAdminUser, IsOwnerOrAdmin. get_permissions() on ViewSets returns different classes per action. Object-level permissions for row-level security.
djangorestframework — built-in
🔒
Field-level AES encryption
Custom Django model field (EncryptedCharField) using Fernet (AES-128-CBC + HMAC-SHA256). Encrypts on get_prep_value, decrypts on from_db_value. Transparent to the rest of the app.
cryptography ^42.x
🔑
Argon2 password hashing
PASSWORD_HASHERS setting puts Argon2 first — Django uses it automatically for all set_password() calls. Zero code changes — just add django[argon2] to requirements and update settings.
django[argon2]
⏱️
Rate limiting / throttling
DRF AnonRateThrottle and UserRateThrottle with Redis backend via django-redis. Custom LoginThrottle at 10/min for auth endpoints. BurstUserThrottle (60/min) + SustainedUserThrottle (1000/day).
djangorestframework · django-redis
Celery async tasks
@shared_task with bind=True for retries. autoretry_for=(Exception,) + max_retries=3. acks_late=True for at-least-once delivery. CELERY_TASK_ALWAYS_EAGER=True in tests for synchronous execution.
celery ^5.x · redis
🔌
WebSocket via Channels
Django Channels with AsyncWebsocketConsumer. Redis channel layer for multi-process pub/sub. JWT validation in connect(). group_send() for broadcasting. async_to_sync() to push from sync code (Celery tasks).
channels ^4.x · channels-redis
💾
Redis cache layer
django-redis as CACHE backend. @cache_result decorator on selectors for transparent caching. invalidate_cache() called in services after mutations. Cache keys namespaced by resource type.
django-redis ^5.x
📋
DRF Serializers + filtering
ModelSerializer for output. Separate input serializers (no ModelSerializer) for write endpoints. django-filter for query param filtering on list endpoints. Cursor pagination for large datasets.
djangorestframework · django-filter
🧪
Testing — pytest-django
pytest-django with @pytest.mark.django_db. factory_boy for model factories — zero fixtures. APIClient for view tests. Service tests call services directly — no HTTP overhead. Celery eager mode.
pytest-django · factory-boy

06 — Requirements

The pip Registry

Proven, stable, actively maintained. These are the packages that power Django at scale — not trendy alternatives.

requirements/ structure: Split into base.txt, development.txt, and production.txt. Development extends base, production extends base. Never mix dev tools into production images.
PackageVersionPurposeLayer
Django^5.1Core framework — ORM, admin, auth, migrations, signals, middlewarecore
djangorestframework^3.15DRF — ViewSets, serializers, authentication, permissions, throttlingview
django-cors-headers^4.4CORS headers middleware — origin allowlist, credentials, preflightview
django-filter^24.xQuery param filtering for DRF list endpoints — FilterSet + DjangoFilterBackendview
PyJWT^2.9JWT encode/decode — used by custom JWTAuthentication backendauth
cryptography^42.xFernet (AES-128-CBC + HMAC-SHA256) for EncryptedCharField model fieldsecurity
django[argon2]Argon2id password hasher — add to PASSWORD_HASHERS as first entrysecurity
psycopg[binary]^3.2PostgreSQL driver (psycopg3) — async-capable, faster than psycopg2data
django-redis^5.4Redis as Django CACHE backend — used for throttling, session, app cachingdata
celery^5.4Distributed task queue — background jobs, scheduled tasks via beatasync
django-celery-beat^2.7Celery Beat with database-backed schedule — manage periodic tasks via adminasync
channels^4.1Django Channels — ASGI, WebSocket consumers, AuthMiddlewareStackws
channels-redis^4.2Redis channel layer for multi-process WebSocket pub/subws
django-environ^0.11Typed env var parsing — env.db(), env.cache(), env.bool() with defaultsinfra
structlog^24.xStructured JSON logging — key-value pairs, context binding, async-safeinfra
sentry-sdk[django]^2.xError tracking + performance monitoring — auto-instruments Django + Celeryinfra
django-storages[s3]^1.14S3/GCS/Azure file storage backend — swap local media for cloud in productioninfra
gunicorn^22.xWSGI server for HTTP traffic — behind Nginx in productiondeploy
uvicorn[standard]^0.30ASGI server — required for Django Channels WebSocket supportdeploy
pytest-django^4.9pytest plugin — @pytest.mark.django_db, settings fixture, rf (RequestFactory)test
factory-boy^3.3Model factories — DjangoModelFactory, Sequence, Faker — replaces fixturestest
coverage^7.xCoverage reporting — combine with pytest-cov for HTML reportstest
ruff^0.6Extremely fast Python linter + formatter — replaces flake8, isort, blackdev
mypy + django-stubs^1.11Static type checking with Django ORM awareness — catches model field mismatchesdev

✦ — AI Scaffold

Generate with AI

Paste into Claude, ChatGPT, Gemini, or any AI agent to scaffold this exact architecture from scratch.

 Architecture Prompt
You are a senior Django architect. Scaffold a production-ready Django REST API.

Stack:
- Python: 3.12+
- Framework: Django 5.x + Django REST Framework
- Auth: Simple JWT (access + refresh tokens)
- Database: PostgreSQL with psycopg3
- ORM: Django ORM with select_related/prefetch_related
- Cache: Redis (django-redis)
- Queue: Celery + Redis broker + django-celery-beat
- Storage: django-storages + S3
- API Docs: drf-spectacular (OpenAPI 3)
- Testing: pytest-django + factory_boy + freezegun
- Deployment: Gunicorn + Nginx, Docker Compose

Provide:
1. Django project layout (config/, apps/users, apps/core, apps/api)
2. Custom User model with JWT auth flow
3. ViewSet + Router pattern with permission classes
4. Celery task definition + periodic task via beat
5. Model with signals and custom manager
6. pytest fixtures for auth and database
7. docker-compose.yml (web, db, redis, celery, beat)
8. Settings split (base/local/production) with env vars via python-decouple
crafted with by Sam
 Copied to clipboard!