DRF gets you to a working API quickly. ModelViewSet, ModelSerializer, a router, and you have CRUD endpoints in 30 lines of code. The problem is that "working" and "well-designed" are not the same thing, and the gap shows up six months into a project when you are trying to version the API, or when a mobile client is failing because the error shape changed, or when a front-end developer is asking why the same resource looks different depending on which endpoint returns it.
This post covers the patterns we have settled on after building APIs for various clients — not what DRF makes easy by default, but what makes APIs maintainable over time.
A consistent response envelope
The most important decision in API design is the response shape. Pick it once and enforce it everywhere.
We use this structure:
// Success
{
"success": true,
"data": { ... },
"meta": { "page": 1, "total": 142 }
}
// Error
{
"success": false,
"error": {
"message": "Validation failed",
"code": "validation_error",
"errors": {
"email": ["Enter a valid email address."],
"name": ["This field may not be blank."]
}
}
}
We enforce this with a custom renderer and exception handler:
# api/renderers.py
from rest_framework.renderers import JSONRenderer
import json
class EnvelopeRenderer(JSONRenderer):
def render(self, data, accepted_media_type=None, renderer_context=None):
response = renderer_context.get('response')
if response and response.status_code >= 400:
# Error responses are handled by the exception handler
return super().render(data, accepted_media_type, renderer_context)
envelope = {'success': True, 'data': data}
return super().render(envelope, accepted_media_type, renderer_context)
# api/exceptions.py
from rest_framework.views import exception_handler
from rest_framework.response import Response
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is not None:
error = {
'message': 'An error occurred',
'code': 'error',
}
if hasattr(exc, 'detail'):
if isinstance(exc.detail, dict):
# Validation errors
error['message'] = 'Validation failed'
error['code'] = 'validation_error'
error['errors'] = exc.detail
elif isinstance(exc.detail, list):
error['message'] = str(exc.detail[0])
error['code'] = getattr(exc.detail[0], 'code', 'error')
else:
error['message'] = str(exc.detail)
error['code'] = getattr(exc.detail, 'code', 'error')
response.data = {'success': False, 'error': error}
return response
# settings.py
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': ['api.renderers.EnvelopeRenderer'],
'EXCEPTION_HANDLER': 'api.exceptions.custom_exception_handler',
}
Now every success response has success: true and every error has success: false with a consistent error structure. Clients have one place to check for errors.
Serializer patterns
Never use ModelSerializer fields directly in write operations
ModelSerializer maps model fields to serializer fields, which is convenient. The problem is it exposes your internal data model to the API — field names, types, and relationships that might change as the codebase evolves.
We separate read and write serializers:
# api/serializers/orders.py
from rest_framework import serializers
from orders.models import Order, LineItem
class LineItemReadSerializer(serializers.ModelSerializer):
product_name = serializers.CharField(source='product.name', read_only=True)
class Meta:
model = LineItem
fields = ['id', 'product_name', 'quantity', 'unit_price', 'total']
class OrderReadSerializer(serializers.ModelSerializer):
customer_name = serializers.CharField(source='customer.name', read_only=True)
line_items = LineItemReadSerializer(many=True, read_only=True)
status_display = serializers.CharField(source='get_status_display', read_only=True)
class Meta:
model = Order
fields = [
'id', 'reference', 'customer_name',
'status', 'status_display',
'line_items', 'total',
'created_at', 'updated_at',
]
class OrderCreateSerializer(serializers.Serializer):
"""Explicit input validation — not tied to the model fields."""
customer_id = serializers.IntegerField()
line_items = serializers.ListField(
child=serializers.DictField(),
min_length=1,
)
notes = serializers.CharField(required=False, allow_blank=True)
def validate_customer_id(self, value):
from customers.models import Customer
if not Customer.objects.filter(id=value).exists():
raise serializers.ValidationError('Customer not found.')
return value
def validate_line_items(self, value):
for item in value:
if 'product_id' not in item or 'quantity' not in item:
raise serializers.ValidationError(
'Each line item must have product_id and quantity.'
)
if item['quantity'] < 1:
raise serializers.ValidationError('Quantity must be at least 1.')
return value
def create(self, validated_data):
from orders.services import create_order
return create_order(
customer_id=validated_data['customer_id'],
line_items=validated_data['line_items'],
notes=validated_data.get('notes', ''),
)
The read serializer is for output — what the API returns. The write serializer is for input — what the API accepts. They do not need to look the same.
Service layer for business logic
Serializer .create() and .update() should call a service function, not contain business logic themselves:
# orders/services.py
from django.db import transaction
from .models import Order, LineItem
from products.models import Product
@transaction.atomic
def create_order(customer_id: int, line_items: list, notes: str = '') -> Order:
order = Order.objects.create(
customer_id=customer_id,
notes=notes,
status='pending',
)
for item_data in line_items:
product = Product.objects.get(id=item_data['product_id'])
LineItem.objects.create(
order=order,
product=product,
quantity=item_data['quantity'],
unit_price=product.price,
)
order.refresh_from_db()
return order
Business logic in services is testable without the API layer, reusable from management commands and Celery tasks, and keeps serializers focused on validation.
Viewsets with explicit action control
We use ViewSet (not ModelViewSet) to be explicit about which actions are available:
# api/views/orders.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .serializers.orders import (
OrderReadSerializer,
OrderCreateSerializer,
)
from orders.models import Order
from orders.services import create_order, cancel_order
class OrderViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Order.objects.filter(
customer__user=self.request.user
).select_related(
'customer',
).prefetch_related(
'line_items__product',
).order_by('-created_at')
def list(self, request):
queryset = self.get_queryset()
serializer = OrderReadSerializer(queryset, many=True)
return Response(serializer.data)
def retrieve(self, request, pk=None):
order = self.get_queryset().get(pk=pk)
return Response(OrderReadSerializer(order).data)
def create(self, request):
serializer = OrderCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
order = serializer.save()
return Response(
OrderReadSerializer(order).data,
status=status.HTTP_201_CREATED,
)
@action(detail=True, methods=['post'])
def cancel(self, request, pk=None):
order = self.get_queryset().get(pk=pk)
if order.status not in ('pending', 'processing'):
return Response(
{'success': False, 'error': {
'message': 'Only pending or processing orders can be cancelled',
'code': 'invalid_status',
}},
status=status.HTTP_400_BAD_REQUEST,
)
cancel_order(order)
return Response(OrderReadSerializer(order).data)
ModelViewSet generates all CRUD actions automatically. That is fine for simple CRUD, but most APIs have actions that are not CRUD (cancel, approve, archive), and you often want to disable some standard actions. Explicit ViewSet makes the available actions obvious.
Filtering and search
We use django-filter for structured filtering:
# api/filters.py
import django_filters
from orders.models import Order
class OrderFilter(django_filters.FilterSet):
status = django_filters.MultipleChoiceFilter(choices=Order.STATUS_CHOICES)
created_after = django_filters.DateFilter(field_name='created_at', lookup_expr='gte')
created_before = django_filters.DateFilter(field_name='created_at', lookup_expr='lte')
min_total = django_filters.NumberFilter(field_name='total', lookup_expr='gte')
class Meta:
model = Order
fields = ['status', 'created_after', 'created_before', 'min_total']
# In the viewset
from rest_framework.filters import SearchFilter, OrderingFilter
from django_filters.rest_framework import DjangoFilterBackend
class OrderViewSet(viewsets.ViewSet):
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = OrderFilter
search_fields = ['reference', 'customer__name', 'customer__email']
ordering_fields = ['created_at', 'total', 'status']
ordering = ['-created_at']
This gives clients: ?status=pending&status=processing, ?created_after=2024-01-01, ?search=ORD-0042, ?ordering=total.
API versioning
APIs change. Clients — especially mobile apps — cannot always update immediately. Versioning lets you evolve the API without breaking existing clients.
We use URL versioning:
# urls.py
urlpatterns = [
path('api/v1/', include('api.v1.urls')),
path('api/v2/', include('api.v2.urls')),
]
The v2 URLs import and extend v1 views where nothing has changed, and define new views where things have:
# api/v2/views/orders.py
from api.v1.views.orders import OrderViewSet as OrderViewSetV1
class OrderViewSet(OrderViewSetV1):
"""V2: adds line item details to the list response."""
def list(self, request):
# Override with enhanced serializer
queryset = self.get_queryset()
serializer = OrderWithLineItemsSerializer(queryset, many=True)
return Response(serializer.data)
We add a version header to every response so clients always know what version they are talking to:
# api/middleware.py
class APIVersionHeaderMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if request.path.startswith('/api/'):
version = request.path.split('/')[2] # 'v1', 'v2'
response['X-API-Version'] = version
return response
The honest summary
The patterns here — response envelopes, separate read/write serializers, service layer, explicit viewsets, filtering, versioning — are not complicated. They add a little more code than the DRF defaults. What they add in return is an API that stays maintainable as the project grows.
The two things we see skipped most often are the response envelope and the service layer. Both seem like overhead early in a project. By the time you realise you need them, the cost of adding them is much higher than if you had started with them.
Lycore designs and builds REST APIs for businesses — Django backends, mobile APIs, third-party integrations, and custom software. Get in touch if you need an API built to last.
Top comments (0)