LQ Digital Nepal

10 Django REST API Patterns I Wish I Knew Earlier (Learned From Production)

Backend DevelopmentJune 24, 2026·12 min read

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.

Written by

Mishan Shah

Ready to elevate your brand from idea to Results

LQ Digital Nepal

Quick Links

Services

Solutions

Booking & Scheduling Systems

Client Management Systems (CRM)

Custom Dashboards & Portals

E-Commerce Enhancements

Automation Tools

Reporting Templates

Connect with Us

socialsocialsocialsocialsocialsocialsocial

© 2026 LQ Digital Nepal. All rights reserved.

Privacy Policy