Over the years, I have worked on plenty of Django projects where the views started clean and gradually became difficult to maintain.
It rarely happens intentionally.
A view begins by loading a form, saving a model and returning a response. Then another requirement arrives. A permission check is added. A related record needs to be created. An email needs to be sent. An audit field needs to be populated.
Before long, the view is responsible for far more than handling an HTTP request.
My approach is to keep views thin.
A view should understand HTTP. It should process the incoming request, validate the request data, call the relevant application logic and return the correct response.
It should not be the main home for business logic.
What is a thin Django view?
At its simplest, a Django view accepts an HTTP request and returns an HTTP response.
That does not mean every view must be three lines long. It means the responsibilities inside the view should be related to the request and response cycle.
A view can reasonably be responsible for:
- Checking whether the user is authenticated
- Restricting the accepted HTTP methods
- Binding request data to a form or serializer
- Returning validation errors
- Calling a service or application function
- Selecting a template
- Returning a redirect, JSON response or HTTP status code
A view should not normally be responsible for:
- Enforcing business rules
- Coordinating changes across several models
- Deciding how records are created or updated
- Managing audit fields
- Sending notifications
- Implementing reusable permissions or ownership rules
- Duplicating logic that is also needed by an API or background task
The difference is not always obvious when a project is small. It becomes much more obvious as the application grows.
An example of a view doing too much
Consider a basic project creation view.
The application allows each user to create up to ten active projects. Every project must record who created it, and its name should be stripped before it is saved.
A view containing all of that logic might look like this:
from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render from django.views.decorators.http import require_http_methods from example_app.forms import ProjectForm from example_app.models import Project @login_required @require_http_methods(["POST"]) def project_create(request): form = ProjectForm(request.POST) if not form.is_valid(): return render( request, "example_app/project_form.html", {"form": form}, ) active_projects = Project.objects.filter( created_by=request.user, status=Project.Status.ACTIVE, ).count() if active_projects >= 10: form.add_error( None, "You cannot have more than ten active projects.", ) return render( request, "example_app/project_form.html", {"form": form}, ) project = Project( name=form.cleaned_data["name"].strip(), description=form.cleaned_data["description"], status=form.cleaned_data["status"], created_by=request.user, updated_by=request.user, ) project.full_clean() project.save() return redirect("example_app:detail", guid=project.guid)
There is nothing technically invalid about this code. It will work.
The problem is that the view now knows:
- How project ownership works
- How audit fields are populated
- How project names are prepared
- How many active projects a user can have
- How a project should be validated and saved
Those are application rules, not HTTP rules.
If I later add a Django REST Framework endpoint, an import process or a Celery task that creates projects, I have to repeat those rules or somehow call the view.
Neither option is particularly good.
Separating input validation from business logic
I use Django forms to validate input coming from an HTML request.
from django import forms from example_app.models import Project class ProjectForm(forms.Form): name = forms.CharField(max_length=255) description = forms.CharField( required=False, widget=forms.Textarea, ) status = forms.ChoiceField( choices=Project.Status.choices, initial=Project.Status.DRAFT, )
The form answers questions such as:
- Was a name supplied?
- Is the name within the maximum length?
- Is the status one of the supported choices?
- Can the submitted values be converted into the expected Python types?
These are input validation concerns.
The rule that a user cannot have more than ten active projects is different. That rule applies regardless of whether the project is created through an HTML form, a REST API or an internal task.
That belongs outside the form and outside the view.
Using a DTO between the view and service
In larger applications, I often use a small data transfer object to define the information being passed into the service.
from dataclasses import asdict, dataclass from example_app.models import Project @dataclass(frozen=True, slots=True) class ProjectPayload: name: str description: str = "" status: str = Project.Status.DRAFT def as_dict(self) -> dict: return asdict(self)
This is not required for every project, but I find it useful.
Instead of passing an unstructured dictionary around the application, the service receives a typed and explicit payload.
It also gives the web form and REST API serializer a shared shape to produce.
ProjectForm
|
+----> ProjectPayload ----> ProjectService
ProjectWriteSerializer
|
+----> ProjectPayload ----> ProjectService
The form and serializer remain responsible for their respective input formats. The service receives the same application-level object from both.
Moving the business logic into a service
The service becomes the main entry point for creating a project.
from django.db import transaction from core.services import BaseService from example_app.models import Project from .dtos import ProjectPayload class ProjectLimitReached(Exception): pass class ProjectService(BaseService[Project]): model = Project def get_queryset(self): return super().get_queryset().filter(created_by=self.user) @transaction.atomic def create_project(self, payload: ProjectPayload) -> Project: active_projects = self.get_queryset().filter( status=Project.Status.ACTIVE, ).count() if active_projects >= 10: raise ProjectLimitReached return self.create( name=payload.name.strip(), description=payload.description, status=payload.status, )
The service now owns the rules for creating a project.
It scopes queries to the current user, checks the active project limit and creates the record through a shared persistence method.
The transaction boundary also lives around the complete operation. This becomes particularly important when one action changes several records.
For example, creating an order might also create order lines, reserve stock, record an audit event and queue a confirmation email. Those changes belong to one application operation, not scattered throughout a view.
The thin version of the view
With the business logic moved into the service, the view becomes much easier to understand.
from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render from django.views.decorators.http import require_http_methods from example_app.dtos import ProjectPayload from example_app.forms import ProjectForm from example_app.services import ( ProjectLimitReached, ProjectService, ) @login_required @require_http_methods(["POST"]) def project_create(request): form = ProjectForm(request.POST) if not form.is_valid(): return render( request, "example_app/project_form.html", {"form": form}, ) service = ProjectService(user=request.user) try: project = service.create_project( ProjectPayload(**form.cleaned_data) ) except ProjectLimitReached: form.add_error( None, "You cannot have more than ten active projects.", ) return render( request, "example_app/project_form.html", {"form": form}, ) return redirect( "example_app:detail", guid=project.guid, )
The view is now focused on the HTTP interaction:
- Bind the submitted request data to a form.
- Return the form when the data is invalid.
- Convert the validated data into an application payload.
- Call the project service.
- Translate a known business error into a form error.
- Return a redirect.
The view does not need to understand how the project is saved or why a user might be prevented from creating one.
It only needs to know what service operation to call and how the result should be represented over HTTP.
My base service approach
In my Django starter projects, I use a small generic base service for common persistence behaviour.
A simplified version looks like this:
from typing import Generic, TypeVar from django.utils import timezone from core.models import BaseModel TModel = TypeVar("TModel", bound=BaseModel) class BaseService(Generic[TModel]): model: type[TModel] def __init__(self, user=None): self.user = ( user if user is not None and user.is_authenticated else None ) def get_queryset(self): return self.model.objects.all() def get(self, guid) -> TModel: return self.get_queryset().get(guid=guid) def list(self, **filters): return self.get_queryset().filter(**filters) def create(self, **fields) -> TModel: instance = self.model(**fields) instance.created_by = self.user instance.updated_by = self.user instance.full_clean() instance.save() return instance def update(self, instance: TModel, **fields) -> TModel: for name, value in fields.items(): setattr(instance, name, value) instance.updated_by = self.user instance.full_clean() instance.save() return instance def soft_delete(self, instance: TModel) -> TModel: instance.deleted_at = timezone.now() instance.deleted_by = self.user instance.save( update_fields=[ "deleted_at", "deleted_by", "updated_at", ] ) return instance
This gives each domain service a consistent foundation for common operations such as creating, updating, querying and soft deleting records.
It also ensures that audit fields and model validation are applied consistently.
The inheritance is not the important part, though. You can use standalone functions, plain service classes or command objects instead.
The important part is having one clear path through which the business operation is performed.
Avoid creating services that only rename the ORM
A service layer can become pointless if every method is just a slightly different name for an ORM call.
class ProjectService: def get_project(self, guid): return Project.objects.get(guid=guid) def create_project(self, **fields): return Project.objects.create(**fields) def update_project(self, project, **fields): for name, value in fields.items(): setattr(project, name, value) project.save() return project
There is little value in this on its own.
A useful service method should normally express an application operation or business intention.
service.create_project(payload) service.archive_project(project) service.transfer_project(project, new_owner) service.activate_project(project)
These method names tell me what the application is doing.
They also give the relevant business rules somewhere sensible to live.
A generic base service can still be useful for common persistence behaviour, but the public methods on a domain service should describe the operations that matter to the application.
Reusing the same logic from a REST API
Once the project creation rules live in a service, the same operation can be used from Django REST Framework.
from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView from api.serializers import ( ProjectSerializer, ProjectWriteSerializer, ) from example_app.services import ProjectService class ProjectCreateView(APIView): def post(self, request): serializer = ProjectWriteSerializer(data=request.data) serializer.is_valid(raise_exception=True) project = ProjectService( user=request.user ).create_project( serializer.to_payload() ) return Response( ProjectSerializer(project).data, status=status.HTTP_201_CREATED, )
The HTML view and API view handle different types of requests and return different types of responses.
They still use the same project creation rules.
That is one of the main reasons I use a service layer. Business behaviour should not change depending on which interface happened to trigger it.
Services are easier to test directly
When business logic lives in a view, testing it normally requires constructing an HTTP request and checking the resulting response.
You should still test your views, but application rules can be tested more directly through the service.
import pytest from example_app.dtos import ProjectPayload from example_app.models import Project from example_app.services import ( ProjectLimitReached, ProjectService, ) @pytest.mark.django_db def test_user_cannot_create_more_than_ten_active_projects(user): service = ProjectService(user=user) for number in range(10): service.create( name=f"Project {number}", status=Project.Status.ACTIVE, ) payload = ProjectPayload( name="One project too many", status=Project.Status.ACTIVE, ) with pytest.raises(ProjectLimitReached): service.create_project(payload)
This test is focused entirely on the business rule.
There is no URL, request factory, template or response involved. If the HTML interface is replaced or an API is added, this test remains valid.
The view tests can then stay focused on HTTP behaviour:
- Does an unauthenticated request get rejected?
- Does invalid form data return the form with errors?
- Does a successful request redirect correctly?
- Is the correct HTTP status returned?
This separation usually makes both sets of tests smaller and clearer.
Where should different types of logic live?
There is no single architecture that fits every Django project, but this is the rough split I use.
| Responsibility | Location |
|---|---|
| HTTP methods, redirects and responses | View |
| Parsing and validating HTML form input | Form |
| Parsing and validating API input | Serializer |
| Business operations and workflows | Service |
| Data structure and database relationships | Model |
| Rules that are intrinsic to one model | Model method |
| Reusable query construction | QuerySet or manager |
| Cross-model transactions | Service |
| Background execution | Celery task calling a service |
| Rendering data | Template or serializer |
The boundaries will not always be perfect.
A model method can still contain behaviour that naturally belongs to that model. A custom queryset can still handle complex filtering. A form can still contain validation that only applies to that particular form.
The goal is not to move every line of code into a service.
The goal is to stop HTTP views from becoming the default location for anything that does not have an obvious home.
How thin should a view be?
I do not judge a view by its number of lines.
A twenty-line view can be perfectly reasonable. A five-line view can still hide poor boundaries if it calls an oversized helper function that does everything.
Instead, I ask a few questions:
- Does this code need to know that the request came over HTTP?
- Would this rule also apply if the operation came from an API or background task?
- Is the view coordinating changes across several models?
- Am I likely to repeat this logic elsewhere?
- Can I test the business rule without constructing a request?
If the logic does not depend on HTTP, it probably does not belong in the view.
Do all Django projects need a service layer?
No.
For a small content website or a straightforward CRUD application, introducing forms, DTOs, services and domain exceptions for every operation may add more structure than value.
I introduce the service layer when the application has meaningful business behaviour.
That normally includes applications with:
- Several interfaces, such as HTML pages, APIs and background tasks
- Rules that apply across multiple models
- Auditing or ownership requirements
- Multi-step workflows
- External integrations
- Operations that need transaction boundaries
- Logic that needs to be reused and tested independently
The service layer should reduce complexity, not simply move it into a different file.
Start with the simplest structure that keeps the responsibilities clear. Add stronger boundaries when the application needs them.
Final thoughts
Django makes it very easy to put logic inside a view. That is useful when starting a feature, but it can also allow views to grow without anyone noticing.
The rule I use is straightforward:
A view should process an HTTP request, call the relevant application logic and return an HTTP response.
Forms and serializers validate the incoming data. Services perform the business operation. Models represent the data and protect their own invariants.
Keeping those responsibilities separate makes the application easier to understand, easier to test and much easier to extend when the next interface or requirement arrives.
Thin views are not about writing less code.
They are about putting the code in the right place.