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.
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.
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.
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
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'])]
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)
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)
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)
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)
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)
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
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'
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'], )
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
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()
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', ]
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', }, }
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 }, }
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
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) ) ), })
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'], }))
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', } )
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', }
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()
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
View → Service → Selector → DB
Registration → Celery task
WebSocket message broadcast
Service → push WS event (e.g. from Celery task)
Error cascade — AppException → DRF response
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.
Pure Python — zero deps
Django ORM — built-in
PyJWT ^2.x
djangorestframework — built-in
cryptography ^42.x
django[argon2]
djangorestframework · django-redis
celery ^5.x · redis
channels ^4.x · channels-redis
django-redis ^5.x
djangorestframework · django-filter
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.
base.txt, development.txt, and production.txt. Development extends base, production extends base. Never mix dev tools into production images.
| Package | Version | Purpose | Layer |
|---|---|---|---|
Django | ^5.1 | Core framework — ORM, admin, auth, migrations, signals, middleware | core |
djangorestframework | ^3.15 | DRF — ViewSets, serializers, authentication, permissions, throttling | view |
django-cors-headers | ^4.4 | CORS headers middleware — origin allowlist, credentials, preflight | view |
django-filter | ^24.x | Query param filtering for DRF list endpoints — FilterSet + DjangoFilterBackend | view |
PyJWT | ^2.9 | JWT encode/decode — used by custom JWTAuthentication backend | auth |
cryptography | ^42.x | Fernet (AES-128-CBC + HMAC-SHA256) for EncryptedCharField model field | security |
django[argon2] | — | Argon2id password hasher — add to PASSWORD_HASHERS as first entry | security |
psycopg[binary] | ^3.2 | PostgreSQL driver (psycopg3) — async-capable, faster than psycopg2 | data |
django-redis | ^5.4 | Redis as Django CACHE backend — used for throttling, session, app caching | data |
celery | ^5.4 | Distributed task queue — background jobs, scheduled tasks via beat | async |
django-celery-beat | ^2.7 | Celery Beat with database-backed schedule — manage periodic tasks via admin | async |
channels | ^4.1 | Django Channels — ASGI, WebSocket consumers, AuthMiddlewareStack | ws |
channels-redis | ^4.2 | Redis channel layer for multi-process WebSocket pub/sub | ws |
django-environ | ^0.11 | Typed env var parsing — env.db(), env.cache(), env.bool() with defaults | infra |
structlog | ^24.x | Structured JSON logging — key-value pairs, context binding, async-safe | infra |
sentry-sdk[django] | ^2.x | Error tracking + performance monitoring — auto-instruments Django + Celery | infra |
django-storages[s3] | ^1.14 | S3/GCS/Azure file storage backend — swap local media for cloud in production | infra |
gunicorn | ^22.x | WSGI server for HTTP traffic — behind Nginx in production | deploy |
uvicorn[standard] | ^0.30 | ASGI server — required for Django Channels WebSocket support | deploy |
pytest-django | ^4.9 | pytest plugin — @pytest.mark.django_db, settings fixture, rf (RequestFactory) | test |
factory-boy | ^3.3 | Model factories — DjangoModelFactory, Sequence, Faker — replaces fixtures | test |
coverage | ^7.x | Coverage reporting — combine with pytest-cov for HTML reports | test |
ruff | ^0.6 | Extremely fast Python linter + formatter — replaces flake8, isort, black | dev |
mypy + django-stubs | ^1.11 | Static type checking with Django ORM awareness — catches model field mismatches | dev |
✦ — AI Scaffold
Generate with AI
Paste into Claude, ChatGPT, Gemini, or any AI agent to scaffold this exact architecture from scratch.
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