Initial commit: add all skills files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
466
fullstack-dev/references/django-best-practices.md
Normal file
466
fullstack-dev/references/django-best-practices.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# Django Best Practices
|
||||
|
||||
Production-grade guide for Django 5.x and Django REST Framework. 40+ rules across 8 categories.
|
||||
|
||||
## Core Principles (7 Rules)
|
||||
|
||||
```
|
||||
1. ✅ Custom User model BEFORE first migration (can't change later)
|
||||
2. ✅ One Django app per domain concept (users, orders, payments)
|
||||
3. ✅ Fat models, thin views — business logic in models/managers, not views
|
||||
4. ✅ Always use select_related/prefetch_related (prevent N+1)
|
||||
5. ✅ Settings split by environment (base + dev + prod)
|
||||
6. ✅ Test with pytest-django + factory_boy (not fixtures)
|
||||
7. ✅ Never use runserver in production (Gunicorn + Nginx)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Structure (CRITICAL)
|
||||
|
||||
### App-Per-Domain
|
||||
|
||||
```
|
||||
myproject/
|
||||
├── config/ # Project config
|
||||
│ ├── __init__.py
|
||||
│ ├── settings/
|
||||
│ │ ├── base.py # Shared settings
|
||||
│ │ ├── dev.py # DEBUG=True, SQLite ok
|
||||
│ │ └── prod.py # DEBUG=False, Postgres, HTTPS
|
||||
│ ├── urls.py
|
||||
│ ├── wsgi.py
|
||||
│ └── asgi.py
|
||||
├── apps/
|
||||
│ ├── users/ # Custom User model
|
||||
│ │ ├── models.py
|
||||
│ │ ├── serializers.py
|
||||
│ │ ├── views.py
|
||||
│ │ ├── urls.py
|
||||
│ │ ├── admin.py
|
||||
│ │ ├── services.py # Business logic
|
||||
│ │ ├── selectors.py # Complex queries
|
||||
│ │ └── tests/
|
||||
│ │ ├── test_models.py
|
||||
│ │ ├── test_views.py
|
||||
│ │ └── factories.py
|
||||
│ ├── orders/
|
||||
│ └── payments/
|
||||
├── manage.py
|
||||
├── requirements/
|
||||
│ ├── base.txt
|
||||
│ ├── dev.txt
|
||||
│ └── prod.txt
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
```
|
||||
✅ One app = one bounded context (users, orders, payments)
|
||||
✅ Business logic in services.py / selectors.py, not views
|
||||
✅ Each app has its own urls.py, admin.py, tests/
|
||||
|
||||
❌ Never put everything in one app
|
||||
❌ Never import across app boundaries at the model level (use IDs)
|
||||
❌ Never put business logic in views or serializers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Models & Migrations (CRITICAL)
|
||||
|
||||
### Custom User Model (Day 1!)
|
||||
|
||||
```python
|
||||
# apps/users/models.py
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
import uuid
|
||||
|
||||
class User(AbstractUser):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
email = models.EmailField(unique=True)
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username']
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
|
||||
# config/settings/base.py
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
```
|
||||
|
||||
**This MUST be done before `migrate`. Cannot change after.**
|
||||
|
||||
### Model Best Practices
|
||||
|
||||
```python
|
||||
class TimeStampedModel(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
class Order(TimeStampedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='orders')
|
||||
status = models.CharField(max_length=20, choices=OrderStatus.choices, default=OrderStatus.PENDING, db_index=True)
|
||||
total = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
|
||||
class Meta:
|
||||
db_table = 'orders'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'status']),
|
||||
]
|
||||
|
||||
def can_cancel(self) -> bool:
|
||||
return self.status in [OrderStatus.PENDING, OrderStatus.CONFIRMED]
|
||||
|
||||
def cancel(self):
|
||||
if not self.can_cancel():
|
||||
raise ValueError(f"Cannot cancel order in {self.status} status")
|
||||
self.status = OrderStatus.CANCELLED
|
||||
self.save(update_fields=['status', 'updated_at'])
|
||||
```
|
||||
|
||||
### Migration Rules
|
||||
|
||||
```
|
||||
✅ Review migration SQL: python manage.py sqlmigrate app_name 0001
|
||||
✅ Name migrations descriptively: --name add_status_index_to_orders
|
||||
✅ Separate data migrations from schema migrations
|
||||
✅ Non-destructive first: add column → backfill → remove old column
|
||||
|
||||
❌ Never edit or delete applied migrations
|
||||
❌ Never use RunPython without reverse function
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Views & Serializers — DRF (HIGH)
|
||||
|
||||
### Service Layer Pattern
|
||||
|
||||
```python
|
||||
# apps/orders/services.py
|
||||
from django.db import transaction
|
||||
|
||||
class OrderService:
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_order(user, items_data: list[dict]) -> Order:
|
||||
total = sum(item['price'] * item['quantity'] for item in items_data)
|
||||
order = Order.objects.create(user=user, total=total)
|
||||
OrderItem.objects.bulk_create([
|
||||
OrderItem(order=order, **item) for item in items_data
|
||||
])
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def cancel_order(order_id: str, user) -> Order:
|
||||
order = Order.objects.select_for_update().get(id=order_id, user=user)
|
||||
order.cancel()
|
||||
return order
|
||||
```
|
||||
|
||||
### Serializers
|
||||
|
||||
```python
|
||||
class OrderSerializer(serializers.ModelSerializer):
|
||||
items = OrderItemSerializer(many=True, read_only=True)
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['id', 'status', 'total', 'items', 'created_at']
|
||||
read_only_fields = ['id', 'total', 'created_at']
|
||||
|
||||
class CreateOrderSerializer(serializers.Serializer):
|
||||
"""Input-only serializer — separate from output."""
|
||||
items = serializers.ListField(
|
||||
child=serializers.DictField(), min_length=1, max_length=50,
|
||||
)
|
||||
def validate_items(self, items):
|
||||
for item in items:
|
||||
if item.get('quantity', 0) < 1:
|
||||
raise serializers.ValidationError("Quantity must be at least 1")
|
||||
return items
|
||||
```
|
||||
|
||||
### Views (Thin!)
|
||||
|
||||
```python
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def create_order(request):
|
||||
serializer = CreateOrderSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
order = OrderService.create_order(request.user, serializer.validated_data['items'])
|
||||
return Response({'data': OrderSerializer(order).data}, status=status.HTTP_201_CREATED)
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
```
|
||||
✅ Separate input serializers from output serializers
|
||||
✅ Views only: validate → call service → serialize → respond
|
||||
✅ Use @transaction.atomic for multi-model writes
|
||||
|
||||
❌ Never put business logic in views or serializers
|
||||
❌ Never use ModelSerializer for write operations (too implicit)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Authentication (HIGH)
|
||||
|
||||
| Method | When | Frontend |
|
||||
|--------|------|----------|
|
||||
| Session | Same-domain, SSR, Django templates | Django templates / htmx |
|
||||
| JWT | Different domain, SPA, mobile | React, Vue, mobile apps |
|
||||
| OAuth2 | Third-party login, API consumers | Any |
|
||||
|
||||
### JWT Config (djangorestframework-simplejwt)
|
||||
|
||||
```python
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance Optimization (HIGH)
|
||||
|
||||
### N+1 Query Prevention
|
||||
|
||||
```python
|
||||
# ❌ N+1: 1 query for orders + N queries for users
|
||||
orders = Order.objects.all()
|
||||
for o in orders:
|
||||
print(o.user.email) # hits DB each iteration
|
||||
|
||||
# ✅ select_related (FK/OneToOne — JOIN)
|
||||
orders = Order.objects.select_related('user').all()
|
||||
|
||||
# ✅ prefetch_related (ManyToMany/reverse FK — 2 queries)
|
||||
orders = Order.objects.prefetch_related('items').all()
|
||||
|
||||
# ✅ Combined
|
||||
orders = Order.objects.select_related('user').prefetch_related('items').all()
|
||||
```
|
||||
|
||||
### Query Optimization Toolkit
|
||||
|
||||
```python
|
||||
# Only fetch needed columns
|
||||
User.objects.values('id', 'email')
|
||||
User.objects.values_list('email', flat=True)
|
||||
|
||||
# Annotate instead of Python loops
|
||||
from django.db.models import Count, Sum
|
||||
Order.objects.annotate(item_count=Count('items'), revenue=Sum('items__price'))
|
||||
|
||||
# Bulk operations
|
||||
OrderItem.objects.bulk_create([...])
|
||||
Order.objects.filter(status='pending').update(status='cancelled')
|
||||
|
||||
# Database indexes
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'status']),
|
||||
models.Index(fields=['-created_at']),
|
||||
models.Index(fields=['email'], condition=Q(is_active=True)),
|
||||
]
|
||||
|
||||
# Pagination
|
||||
from rest_framework.pagination import CursorPagination
|
||||
class OrderPagination(CursorPagination):
|
||||
page_size = 20
|
||||
ordering = '-created_at'
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
```python
|
||||
from django.core.cache import cache
|
||||
|
||||
def get_product(product_id: str):
|
||||
cache_key = f'product:{product_id}'
|
||||
product = cache.get(cache_key)
|
||||
if product is None:
|
||||
product = Product.objects.get(id=product_id)
|
||||
cache.set(cache_key, product, timeout=300)
|
||||
return product
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing (MEDIUM-HIGH)
|
||||
|
||||
### pytest-django + factory_boy
|
||||
|
||||
```python
|
||||
# conftest.py
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(api_client, user_factory):
|
||||
user = user_factory()
|
||||
api_client.force_authenticate(user=user)
|
||||
return api_client
|
||||
```
|
||||
|
||||
```python
|
||||
# factories.py
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = User
|
||||
email = factory.Sequence(lambda n: f'user{n}@example.com')
|
||||
username = factory.Sequence(lambda n: f'user{n}')
|
||||
|
||||
class OrderFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = 'orders.Order'
|
||||
user = factory.SubFactory(UserFactory)
|
||||
total = factory.Faker('pydecimal', left_digits=3, right_digits=2, positive=True)
|
||||
```
|
||||
|
||||
```python
|
||||
# test_views.py
|
||||
@pytest.mark.django_db
|
||||
class TestListOrders:
|
||||
def test_returns_user_orders(self, authenticated_client):
|
||||
OrderFactory.create_batch(3, user=authenticated_client.handler._force_user)
|
||||
response = authenticated_client.get('/api/orders/')
|
||||
assert response.status_code == 200
|
||||
assert len(response.data['data']) == 3
|
||||
|
||||
def test_requires_authentication(self, api_client):
|
||||
response = api_client.get('/api/orders/')
|
||||
assert response.status_code == 401
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Admin Customization (MEDIUM)
|
||||
|
||||
```python
|
||||
class OrderItemInline(admin.TabularInline):
|
||||
model = OrderItem
|
||||
extra = 0
|
||||
readonly_fields = ['price']
|
||||
|
||||
@admin.register(Order)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'user', 'status', 'total', 'created_at']
|
||||
list_filter = ['status', 'created_at']
|
||||
search_fields = ['user__email', 'id']
|
||||
readonly_fields = ['id', 'created_at', 'updated_at']
|
||||
inlines = [OrderItemInline]
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('user')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Production Deployment (MEDIUM)
|
||||
|
||||
### Security Settings
|
||||
|
||||
```python
|
||||
# settings/prod.py
|
||||
DEBUG = False
|
||||
ALLOWED_HOSTS = ['example.com', 'www.example.com']
|
||||
CSRF_TRUSTED_ORIGINS = ['https://example.com']
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
```
|
||||
|
||||
### Deployment Stack
|
||||
|
||||
```
|
||||
Nginx → Gunicorn → Django
|
||||
↕
|
||||
PostgreSQL + Redis (cache)
|
||||
↕
|
||||
Celery (background tasks)
|
||||
```
|
||||
|
||||
```bash
|
||||
gunicorn config.wsgi:application \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--workers 4 \
|
||||
--timeout 120 \
|
||||
--access-logfile -
|
||||
```
|
||||
|
||||
### WhiteNoise for Static Files
|
||||
|
||||
```python
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware', # right after Security
|
||||
...
|
||||
]
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
```
|
||||
✅ Gunicorn + Nginx (or Cloud Run / Railway)
|
||||
✅ PostgreSQL (not SQLite)
|
||||
✅ python manage.py check --deploy
|
||||
✅ Sentry for error tracking
|
||||
|
||||
❌ Never use runserver in production
|
||||
❌ Never use DEBUG=True in production
|
||||
❌ Never use SQLite in production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| # | ❌ Don't | ✅ Do Instead |
|
||||
|---|---------|--------------|
|
||||
| 1 | Business logic in views | Service layer (`services.py`) |
|
||||
| 2 | One giant app | App-per-domain |
|
||||
| 3 | Default User model | Custom User before first migrate |
|
||||
| 4 | No `select_related` | Always eager-load related objects |
|
||||
| 5 | Django fixtures for tests | `factory_boy` factories |
|
||||
| 6 | `settings.py` single file | Split: base + dev + prod |
|
||||
| 7 | `runserver` in production | Gunicorn + Nginx |
|
||||
| 8 | SQLite in production | PostgreSQL |
|
||||
| 9 | `ModelSerializer` for writes | Explicit input serializer |
|
||||
| 10 | Raw SQL in views | ORM querysets + `selectors.py` |
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Issue 1: "Can't change User model after first migration"
|
||||
|
||||
**Fix:** If starting fresh: delete all migrations + DB, set custom User, re-migrate. If data exists: complex migration (use `django-allauth` or incremental field migration).
|
||||
|
||||
### Issue 2: "Serializer is too slow on large querysets"
|
||||
|
||||
**Fix:** Missing `select_related` / `prefetch_related` → N+1 queries.
|
||||
```python
|
||||
queryset = Order.objects.select_related('user').prefetch_related('items')
|
||||
```
|
||||
|
||||
### Issue 3: "Circular import between apps"
|
||||
|
||||
**Fix:** Use string references: `models.ForeignKey('orders.Order', ...)` instead of importing the model class. For services, import inside the function.
|
||||
Reference in New Issue
Block a user