I've shipped Django REST APIs for e-commerce, SaaS, and B2B platforms. Most tutorials teach you how to start a project. Nobody talks about what keeps it from falling apart six months later.
Here are 10 patterns that made the difference. Every one came from a real production problem.
1. Split apps by domain, not by type
Most tutorials put everything in one app. Split by domain instead:
myproject/
├── core/ # shared utilities, base classes
├── users/ # auth, user model, permissions
├── products/ # catalogue, SKUs, assets
├── carts/ # cart sessions
├── checkouts/ # checkout flow, payment intents
└── orders/ # order lifecycle
Each app owns its models, serializers, views, urls, and tests. No cross-app model imports except through explicit ForeignKeys. When a domain needs to change, you touch one place.
2. Custom User model from day one
Migrating from Django's default User to a custom model after you have production data is painful. Start with this:
class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True, db_index=True)
is_active = models.BooleanField(default=False) # email verification gate
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
Three things to get right upfront:
- Email as USERNAME_FIELD — no usernames, cleaner UX
- is_active = False by default — force email verification before login
- set_unusable_password() in create_user — enables social auth without a separate model
3. SimpleJWT does not check is_active
This catches everyone. The default TokenObtainPairView will issue tokens to unverified users. Override the serializer:
class ActiveUserTokenObtainPairSerializer(TokenObtainPairSerializer):
def validate(self, attrs):
user = authenticate(...)
if user is None:
raise AuthenticationFailed("Invalid credentials.")
if not user.is_active:
raise AuthenticationFailed("Email not verified.")
refresh = self.get_token(user)
refresh["is_staff"] = user.is_staff
refresh["email"] = user.email
return {
"refresh": str(refresh),
"access": str(refresh.access_token),
}
Also add scoped throttling to your login endpoint. "login": "1000/day" is a reasonable starting point.
4. DRF throttles punish your own team
The default throttles apply to everyone, including staff using your internal admin dashboard. On a product with a dashboard making hundreds of requests, this breaks things in production.
Subclass UserRateThrottle and short-circuit for staff:
class AdminExemptUserRateThrottle(UserRateThrottle):
def allow_request(self, request, view):
user = getattr(request, "user", None)
if user and user.is_authenticated and user.is_staff:
return True
return super().allow_request(request, view)
Register it as your throttle class.
5. Add OpenTelemetry from the start, not after
Don't add observability after the fact. Retrofitting it into a running production system is expensive and disruptive. With the Django and psycopg2 instrumentors, you get request traces and DB query spans for free:
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
def configure_opentelemetry():
resource = Resource.create({
"service.name": os.getenv("OTEL_SERVICE_NAME", "my-backend"),
"deployment.environment": os.getenv("ENVIRONMENT", "production"),
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
))
)
trace.set_tracer_provider(provider)
DjangoInstrumentor().instrument()
Psycopg2Instrumentor().instrument()
Ship this to Grafana Tempo, Jaeger, or any OTLP-compatible backend. When something goes slow in production, you'll know exactly which DB query caused it. The alternative is squinting at logs at 2am trying to guess which of your 40 endpoints is the problem.
6. Soft delete is not enough. Hard delete is not safe. You need both.
A plain instance.delete() is irreversible. Most of the time you want to archive, not destroy. But sometimes you really do need to purge.
Soft delete is the default. Hard delete is an explicit opt-in with a referential integrity check:
def destroy(self, request, *args, **kwargs):
product = self.get_object()
hard = str(request.query_params.get("hard", "")).lower() in ("1", "true", "yes")
if not hard:
product.is_active = False
product.save(update_fields=["is_active"])
return Response(status=204)
if OrderItem.objects.filter(product=product).exists():
count = OrderItem.objects.filter(product=product).count()
return Response(
{"detail": f"Cannot hard-delete: referenced by {count} order items."},
status=409,
)
with transaction.atomic():
product.delete()
return Response(status=204)
One endpoint. Two behaviours. No accidental data loss.
7. icontains returns results. It doesn't rank them.
A search for "Porcelain" returns products in random order. The most relevant result could be last. Fix it with a Case/When annotation — pure ORM, no external search engine:
queryset = queryset.annotate(
search_rank=Case(
When(name__iexact=value, then=Value(100)),
When(name__istartswith=value, then=Value(50)),
When(name__icontains=value, then=Value(10)),
default=Value(0),
output_field=IntegerField(),
)
).order_by('-search_rank', '-created_at')
Exact match floats to the top. Contains match is still there, just ranked lower. Works well up to tens of thousands of products before you need Elasticsearch.
8. Order creation logic does not belong in a view
Views handle HTTP. Services handle business logic.
A checkout flow involves at minimum: validating cart ownership, an idempotency check to prevent double orders on network retry, a row-level lock on SKUs, stock revalidation under that lock, order creation, stock decrement, cart cleanup, and an email after commit. That is not a view. That is a service function:
@transaction.atomic
def create_order_from_cart(cart, *, user, idempotency_key=None):
if idempotency_key:
existing = Order.objects.filter(
user=user, idempotency_key=idempotency_key
).first()
if existing:
return existing
skus = {
s.id: s for s in SKU.objects
.select_for_update(of=("self",))
.filter(id__in=cart.items.values_list("sku_id", flat=True))
}
for cart_item in cart.items.all():
sku = skus.get(cart_item.sku_id)
if not sku or sku.stock < cart_item.quantity:
raise ValidationError(f"Insufficient stock for {cart_item.sku_code}")
order = Order.objects.create(user=user, idempotency_key=idempotency_key)
OrderItem.objects.bulk_create([...])
transaction.on_commit(lambda: send_order_emails(order))
return order
9. Immutable order snapshots
Never store just a FK to a product on an order item. Product names change. Prices change. SKU codes get retired. Orders must reflect what the customer actually bought at the time they bought it.
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE)
sku = models.ForeignKey("products.SKU", on_delete=models.PROTECT)
# Snapshot fields — never change after creation
product_name = models.CharField(max_length=255)
sku_code = models.CharField(max_length=64)
unit_price = models.DecimalField(max_digits=12, decimal_places=2)
quantity = models.PositiveIntegerField()
line_total = models.DecimalField(max_digits=12, decimal_places=2)
The FK is for reporting and joins. The snapshot fields are the source of truth.
10. transaction.on_commit for every side effect
Sending an email inside a transaction that rolls back means the customer gets a confirmation for an order that doesn't exist.
transaction.on_commit(lambda: send_order_emails(order))
This applies to anything external: emails, webhooks, Celery tasks, Slack notifications. If it talks to the outside world, it goes in on_commit.
The pattern behind the patterns
None of these are clever. They're the boring fixes you make after something goes wrong in production.
The custom User model exists because someone migrated it late and spent a week on data surgery. The is_active JWT check exists because unverified users were hitting paid features. The on_commit rule exists because customers got order confirmations for failed checkouts.
Ship fast. But build the foundations that let you ship fast six months from now too.

