anishfyi.com Projects All writings

May 12, 20268 min readDjango, DRF, API

Designing Clean REST APIs with Django REST Framework

How I structure serializers, views, and routers in Django REST Framework so API code stays predictable as a project grows.

Django REST Framework hands you a lot of power on day one. The trap is letting that power leak everywhere. After building a vendor seller portal with dozens of endpoints, the rule that kept the code readable was simple: every layer does exactly one job.

Three layers, one job each

A request that comes in clean and predictable usually passes through three places, and each owns a single concern.

Serializers define the contract

The serializer is the public face of your API. Keep it explicit about which fields are readable, which are writable, and what counts as valid.

from rest_framework import serializers
from orders.models import Order


class OrderSerializer(serializers.ModelSerializer):
    vendor_name = serializers.CharField(source="vendor.name", read_only=True)

    class Meta:
        model = Order
        fields = ["id", "reference", "status", "vendor_name", "total", "created_at"]
        read_only_fields = ["id", "status", "created_at"]

    def validate_total(self, value):
        if value <= 0:
            raise serializers.ValidationError("Total must be positive.")
        return value

Keep views thin

A view should fetch, check, and hand off. The moment it starts making decisions about how an order behaves, that logic belongs on the model or in a service instead.

from rest_framework import permissions, viewsets
from orders.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(vendor=self.request.user.vendor)
            .select_related("vendor")
            .order_by("-created_at")
        )

Notice the queryset is scoped to the requesting user. Authorization that lives in the queryset is far harder to forget than a check buried in a method.

Routers keep URLs honest

A router generates consistent URLs from a viewset, so the URL map never drifts from the code.

from rest_framework.routers import DefaultRouter
from .views import OrderViewSet

router = DefaultRouter()
router.register("orders", OrderViewSet, basename="order")

urlpatterns = router.urls

Pagination and filtering are not optional

A list endpoint without pagination is an outage waiting for your data to grow. Set sane defaults globally so no endpoint can forget them.

REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
    "PAGE_SIZE": 50,
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
    ],
}

Version before you need to

Put /api/v1/ in the URL from the first commit. It costs nothing now and saves a painful migration later, when a breaking change would otherwise have nowhere to live.

What I check before merging an endpoint

None of this is clever. It is just boundaries, held consistently, so the next endpoint looks like the last one.

Working on something in this territory? Book thirty minutes.