Digital Edge Consulting logo
← Back to blog

Django architecture

How to Create Thin Django Views

Django views often start small and gradually become responsible for validation, database queries, business rules and side effects. Here is how I keep views focused on processing HTTP requests and returning responses.

Brett Allard 10 min read 11 code examples

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:

example_app/views.py
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.

example_app/forms.py
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.

example_app/dtos.py
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.

// request_flow
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.

example_app/services.py
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.

example_app/views.py
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:

  1. Bind the submitted request data to a form.
  2. Return the form when the data is invalid.
  3. Convert the validated data into an application payload.
  4. Call the project service.
  5. Translate a known business error into a form error.
  6. 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:

core/services.py
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.

example_app/services.py
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_operations
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.

api/views.py
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.

tests/test_services.py
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.

Responsibilities and where they belong in a Django project
Responsibility Location
HTTP methods, redirects and responsesView
Parsing and validating HTML form inputForm
Parsing and validating API inputSerializer
Business operations and workflowsService
Data structure and database relationshipsModel
Rules that are intrinsic to one modelModel method
Reusable query constructionQuerySet or manager
Cross-model transactionsService
Background executionCelery task calling a service
Rendering dataTemplate 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:

// the_rule

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.

// written_by

Brett Allard

CTO & co-founder

Full-stack software engineer and architect. Django and Wagtail are the backbone of what I deliver, and everything here started as a real decision made under real constraints.

// referenced_in_this_post

Django Starter Kit

The service layer described here ships as core/services.py, alongside the base model, split settings and a tested Django, Tailwind, HTMX, Alpine and DRF stack.

From £129 See what's inside →

Production-grade Django, in your inbox.

One email a fortnight. What I'm building, what broke, and what I'd do differently. No courses, no funnels.

Legal / Privacy Policy

Privacy Policy

Last Updated: July 2026

Digital Edge Consulting Limited ("we", "our", "us") is committed to protecting and respecting your privacy. This policy explains how we process personal data in accordance with the UK General Data Protection Regulation (UK GDPR).

1. Information We Collect

We collect information you provide directly to us when you purchase a product, subscribe to our newsletter, apply for mentoring, or contact us via email or LinkedIn.

2. How We Use Your Information

We use your data to deliver the products and services you have purchased, send you the newsletter you subscribed to, respond to mentoring applications, and communicate with you about your account.

3. Data Sharing and Disclosure

We do not sell or rent your personal data to third parties. We do not share your data with any other provider other than the software platforms we use to undertake our business duties (e.g., our payment processor, email service providers, and communication tools). These providers are contractually obligated to protect your data.

4. Your Rights

Under UK GDPR, you have the right to access the personal data we hold about you, request corrections, or request erasure of your data. To exercise these rights, contact us at info@digitaledgeconsulting.co.uk.

Digital Edge Consulting Limited
Registered in England & Wales: 16170340
Legal / Terms of Service

Terms of Service

Last Updated: July 2026

These terms govern the purchase and use of paid products sold by Digital Edge Consulting Limited ("we", "our", "us"), including the Django Starter Kit. By completing a purchase you accept these terms.

1. Licence

Solo licence: licensed to one named individual, strictly for use by that one person. It may be used in unlimited personal and commercial projects, but it must not be used by, shared with, or made available to any other person, team, or organisation. A Solo licence used by more than one person is a breach of these terms.

Agency licence: licensed to the purchasing organisation, for use by its employees and contracted staff on internal and client projects without limit.

2. Restrictions

You may not resell, redistribute, sublicense, or publicly publish the product source code, in whole or in substantial part, whether modified or not. You may not use the product to build a directly competing starter kit, boilerplate, or template product. Code generated in the course of building your own applications is yours.

3. Ownership

The product source code remains the copyright of Digital Edge Consulting Limited. Your purchase grants a licence to use it; it does not transfer ownership.

4. Updates and refunds

A purchase includes all releases within the purchased major version (for example, all 1.x releases of the Django Starter Kit). Future major versions may be a separate purchase. If the product is not what you expected, email us within 14 days of purchase for a full refund.

5. No warranty

Products are provided "as is", without warranty of any kind. To the fullest extent permitted by law, our total liability arising from any purchase is limited to the amount you paid. Nothing in these terms limits liability that cannot be limited under the law of England and Wales.

6. Payments

Payments are processed by Polar (polar.sh) acting as merchant of record. Your purchase is also subject to Polar's own terms at checkout.

7. Governing law

These terms are governed by the law of England and Wales, and any disputes are subject to the exclusive jurisdiction of its courts. If we do not enforce a part of these terms, that is not a waiver. If any part is found unenforceable, the rest still applies.

Digital Edge Consulting Limited
Registered in England & Wales: 16170340
Contact: info@digitaledgeconsulting.co.uk