Django REST Framework Patterns for Product APIs

#backend #Django #DRF #Python #rest api

Django REST Framework is the usual way to build a product API on top of Django. It makes basic CRUD look easy. Production APIs still need a few patterns around serializers, viewsets, permissions, and querysets, or the project fills up with business logic in the wrong places and tenancy holes in the right ones.

This article stays on those API patterns. It is not a Django-versus-FastAPI comparison, and it is not a guide to the admin. The goal is a DRF API that stays readable as the product grows.

Keep serializers thin

Serializers should validate input, shape output, and map to models. They should not become a second service layer. When a response needs a computed field, a SerializerMethodField is fine for a simple format. When the work is a policy, a price calculation, or a call to another system, put that in a service or model method and let the serializer read the result.

from rest_framework import serializers
from .models import Order


class OrderSerializer(serializers.ModelSerializer):
    formatted_total = serializers.SerializerMethodField()

    class Meta:
        model = Order
        fields = ("id", "customer_name", "total_amount", "formatted_total", "created_at")
        read_only_fields = ("id", "created_at")

    def get_formatted_total(self, obj) -> str:
        return f"{obj.total_amount:.2f}"

Prefer explicit fields over dumping the entire model. ModelSerializer is a useful starting point, not a reason to expose every column. Read-only fields, write-only secrets, and nested representations should be chosen on purpose. If a create flow needs different fields from an update flow, use two serializers rather than one serializer with a tangle of conditionals.

Make viewsets responsible for the queryset and the HTTP shape

A ModelViewSet is a good default when the resource is a true model and the operations are standard. The important method is get_queryset. That is where tenancy and visibility belong. A user should see their own orders, or their accountโ€™s orders, and nothing else. Do not rely on the client to โ€œjust send the right id.โ€

from rest_framework import permissions, viewsets
from .models import Order
from .serializers import OrderSerializer


class OrderViewSet(viewsets.ModelViewSet):
    serializer_class = OrderSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        return (
            Order.objects.filter(account=self.request.user.account)
            .select_related("account")
            .order_by("-created_at")
        )

If a write needs extra work โ€” placing an order, applying a coupon, starting a job โ€” do not hide it in perform_create as a long block of business rules. Call a service from perform_create, then return the saved instance. The viewset still owns HTTP; the service owns the transaction and the rules.

Permissions are part of the product

IsAuthenticated is a floor, not a complete policy. Staff endpoints, account-admin endpoints, and object-level checks should be named and tested. DRFโ€™s permission classes are the right place to answer โ€œmay this user take this action on this object?โ€

from rest_framework.permissions import BasePermission


class IsAccountAdmin(BasePermission):
    def has_permission(self, request, view):
        user = request.user
        return bool(user and user.is_authenticated and getattr(user, "is_account_admin", False))

    def has_object_permission(self, request, view, obj):
        return getattr(obj, "account_id", None) == getattr(request.user, "account_id", None)

Keep permission classes small and composable. A class that also mutates the queryset or sends email is doing too much. Combine classes with DRFโ€™s operator helpers when a route needs โ€œauthenticated and account admin,โ€ and test the denied path as carefully as the allowed one.

Pagination, filtering, and nested resources

List endpoints need pagination from the start. An unbounded list of orders will work in development and hurt in production. Use a consistent pagination style across the API so clients can page without special cases.

Filtering should go through an explicit filter set or query parameters you have documented, not through a raw dump of whatever the client sent into the ORM. Search is another place where it is easy to create expensive queries; constrain it to the fields you meant to expose.

Nested resources are useful when the relationship is real and the list is short: line items on an order, members of an account. They become painful when you nest too deeply or hide a second large collection inside a detail payload. Often a top-level resource with a filter โ€” /line-items?order_id= โ€” is clearer and easier to paginate than a deeply nested route.

Custom actions, via @action, are a good fit for verbs that are not plain CRUD: cancel, retry, resend invoice. Name them in product language, keep their permissions explicit, and avoid turning the viewset into a grab bag of unrelated jobs.

Habits that keep a DRF API maintainable

  • Filter every queryset by tenancy or ownership, even on โ€œdetailโ€ routes.
  • Use select_related and prefetch_related where the serializer needs related data.
  • Return consistent error shapes. DRFโ€™s validation errors are a start; do not mix them with ad-hoc string responses.
  • Version the API when you need to break a contract, not as decoration on day one.
  • Write tests for permissions and for the empty or forbidden queryset, not only for the happy path.

When an endpoint does not map cleanly to a model, skip ModelViewSet. A GenericAPIView or a small APIView is better than forcing a report, a search, or a webhook into a viewset that has no natural queryset. Use viewsets where they fit; do not treat them as the only box in the toolbox.

The same is true of nested writes. Creating an order and its line items in one request can be valid, but the serializer should delegate that to a service that owns the transaction. If the create method is full of create() and update() loops, the rules have already leaked out of the domain.

If you do only a few things well, do these: tighten the serializer contract, filter every queryset, and test the permission you think you already have. Most production issues in DRF APIs are not missing ViewSet features. They are leaked fields, missing tenancy filters, and endpoints that work in tests because the test user can see everything.

Summary

A solid Django REST Framework API keeps serializers thin, puts tenancy in get_queryset, uses permission classes as a real product policy, and treats pagination, filtering, and custom actions as part of the design. Those patterns let you ship a product API quickly without burying rules in the wrong layer or leaving gaps in what a user can see.

If you are designing or cleaning up a DRF API for a Django product, get in touch.


Prakash Tank

Prakash Tank

Full-Stack Architect & Tech Enthusiast. Passionate about building scalable applications and sharing knowledge with the community.