Skip to main content

Command Palette

Search for a command to run...

Structuring Responsibilities in Django REST Framework Projects

Updated
4 min read

In a Django REST Framework application, how should responsibilities be divided?

When we build a DRF APIs, we touch many layers: models for saving data, serializers for validating input, views for endpoints, and sometimes extra structure like services and repositories for keeping the code clean.

A simple way to avoid confusion is to ask: what question does each layer answer? Then write code in the layer that answers that question.

Consider the example of a Recipe and Recipe Steps.

Let’s use this real scenario throughout:

Invariant: If a recipe is archived, its steps must also be archived.

Model — “What is this data and what must always be true?”

Models define the core data and basic rules that should remain true regardless of how the model is used (API, admin, scripts).

Example (models):

# models.py

class Recipe(models.Model):
    title = models.CharField(max_length=200)
    is_archived = models.BooleanField(default=False)

class RecipeStep(models.Model):
    recipe = models.ForeignKey(Recipe, related_name="steps", on_delete=models.CASCADE)
    order = models.PositiveIntegerField()
    description = models.TextField()
    is_archived = models.BooleanField(default=False)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["recipe", "order"], name="uniq_step_order_per_recipe")
        ]

Why this belongs in the model?

The “step order must be unique within a recipe” rule is a structural rule, so the model/database is the right place.

Serializer — “Is this request data valid?”

Serializers validate incoming payloads and shape outgoing responses. They are ideal for rules like “title is required” or “steps must have an order and description”.

Example (serializer for creating a recipe with steps):

# serializers.py

from rest_framework import serializers

class RecipeStepInputSerializer(serializers.Serializer):
    order = serializers.IntegerField(min_value=1)
    description = serializers.CharField()

class RecipeCreateSerializer(serializers.Serializer):
    title = serializers.CharField()
    steps = RecipeStepInputSerializer(many=True)

    def validate_steps(self, steps):
        orders = [s["order"] for s in steps]
        if len(orders) != len(set(orders)):
            raise serializers.ValidationError("Step order must be unique.")
        return steps

Why this belongs in the serializer?

  • “Step order must be unique in the request” is input validation.

  • The serializer is checking the incoming data before any database write.

Repository — “How do I fetch/update data?”

Repositories centralize common query patterns, especially when you repeatedly need “recipe with steps”, “prefetch steps ordered”, etc.

Example (repository):

# repositories/recipes.py

from django.db.models import Prefetch
from .models import Recipe, RecipeStep

def get_recipe_with_steps(recipe_id):
    return (
        Recipe.objects
        .filter(id=recipe_id)
        .prefetch_related(
            Prefetch("steps", queryset=RecipeStep.objects.order_by("order"))
        )
        .first()
    )

def archive_steps_for_recipe(recipe_id):
    RecipeStep.objects.filter(recipe_id=recipe_id).update(is_archived=True)

def archive_recipe(recipe_id):
    Recipe.objects.filter(id=recipe_id).update(is_archived=True)

Why this belongs in a repository?

  • It’s purely database access and query reuse.

  • No “business meaning” here—just how we fetch/update efficiently.

Service — “What must happen, and why?”

Services implement business operations and enforce invariants. This is where you express: “archiving a recipe must archive its steps too, and it should happen atomically.

Example (service):

# services/recipes.py

from django.db import transaction
from repositories import recipes as recipe_repo

@transaction.atomic
def archive_recipe_and_steps(recipe_id, actor):
    recipe = recipe_repo.get_recipe_with_steps(recipe_id)
    if recipe is None:
        raise ValueError("Recipe not found")

    # idempotency: calling archive twice should not break things
    if recipe.is_archived:
        return recipe

    # invariant enforcement
    recipe_repo.archive_recipe(recipe_id)
    recipe_repo.archive_steps_for_recipe(recipe_id)

    return recipe

Why this belongs in a service?

  • It coordinates multiple updates.

  • It enforces a system rule (invariant).

  • It defines a transaction boundary.

View — “Who is calling and what response do we return?”

Views are where the request comes in. They should:

  • authenticate/authorize

  • validate input via serializer

  • delegate the actual work to the service

  • return a response

Example (view):

# views.py

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status

from .serializers import RecipeArchiveSerializer
from services.recipes import archive_recipe_and_steps

class RecipeArchiveSerializer(serializers.Serializer):
    recipe_id = serializers.IntegerField()

class ArchiveRecipeView(APIView):
    def post(self, request):
        serializer = RecipeArchiveSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        archive_recipe_and_steps(
            recipe_id=serializer.validated_data["recipe_id"],
            actor=request.user,
        )

        return Response({"status": "archived"}, status=status.HTTP_200_OK)

Why this belongs in a view?

  • This is HTTP-level orchestration: request → validate → call → response.

  • Business logic stays out of the endpoint.

A simple mental map (with Recipe context)

LayerQuestion it answersRecipe/Step example
Model“What is this data and what must always be true?”Step order must be unique per recipe
Serializer“Is this request data valid?”Incoming steps must contain order + description and orders must be unique
Repository“How do I fetch/update data?”Fetch recipe + ordered steps; bulk-update step archive flags
Service“What must happen, and why?”“Archive recipe” must also archive steps, atomically
View“Who is calling and what response do we return?”Validate request, call archive service, return 200

An example code structuring is given below

recipes/
├── models.py
├── serializers/
│   ├── create.py
│   ├── update.py
│   └── detail.py
├── services/
│   └── archive.py
├── repositories/
│   └── recipes.py
├── views/
│   └── archive.py

Thinking in Django & DRF

Part 2 of 13

Thinking in Django & DRF is a series about Django & DRF by understanding why things are designed the way they are. It explores insights from mastering Django & DRF, like syntax, shortcuts, abstraction, invariants, and architectural boundaries.

Up next

Authorization in Django: From Permissions to Policies — Part 13 (Capstone) — Authorization Is Not Security

On Boundaries, Guarantees, and the Limits of Authorization