WRITING / Taste

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.

First principles

A Representational State Transfer (REST) application programming interface (API) is a promise to clients you do not control, served from data that keeps growing, to users who should see only their own records. Four constraints follow from that, and the rest of this post is how each one shapes the code.

  • Clients depend on the shape of the response. Once a field ships, someone reads it, and changing it breaks them. So the shape needs one explicit owner.
  • Every request comes from a specific user. Access has to be decided on every request, and the safest place to decide it is where the data is fetched, not in a check someone has to remember to call.
  • Data grows; a response should not grow with it. A list that returns every row works on day one and fails later. The limits have to be in place before the data arrives.
  • Code changes faster than contracts. The Uniform Resource Locator (URL) and the response format need to stay stable while the code behind them is rewritten, so the version belongs in the URL from the start.

Three layers, one job each

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

  • The serializer owns the shape of the data and its validation.
  • The view owns orchestration: permissions, fetching, and handing work off.
  • The model, manager, or a small service owns the business logic.

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. Once it starts deciding how an order behaves, that logic belongs on the model or in a service.

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

  • The response contract is documented and stable.
  • The queryset is scoped to the requesting user.
  • List responses are paginated.
  • Query counts are checked, so no N+1 query slipped in.
  • There is a test for the unhappy path, not just the happy one.

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

← All writing