DEV Community

Cover image for Django REST Framework API Design: The Patterns We Use on Real Client Projects
Lycore Development
Lycore Development

Posted on

Django REST Framework API Design: The Patterns We Use on Real Client Projects

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."]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# settings.py
REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': ['api.renderers.EnvelopeRenderer'],
    'EXCEPTION_HANDLER': 'api.exceptions.custom_exception_handler',
}
Enter fullscreen mode Exit fullscreen mode

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', ''),
        )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode
# 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']
Enter fullscreen mode Exit fullscreen mode

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')),
]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)