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.
-
The
serializerowns the shape of the data and its validation. -
The
viewowns 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. 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
- 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 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.