Structuring Responsibilities in Django REST Framework Projects
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)
| Layer | Question it answers | Recipe/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



