Django Advanced

Clean Architecture in Django: A Service Layer, Selectors, and Testable Business Logic

Escape fat views and fat models with a pragmatic clean architecture for Django: a service layer for business operations, selectors for reads, clear domain/framework boundaries, transactions and validation in the right place, and business logic you can test without a request.

DjangoZen Team Jul 11, 2026 15 min read 2 views

Django's defaults gently push business logic into two bad places: fat views that mix HTTP handling with domain rules, and fat models that become god-objects nobody dares touch. It works until the app grows, then every change ripples unpredictably and tests need a full request to exercise a rule. This tutorial covers a pragmatic clean architecture for Django — a service layer, selectors, clear boundaries, and honest testing — that keeps business logic findable and changeable without turning a web app into an enterprise cathedral.

The problem with fat views and fat models

Two anti-patterns dominate growing Django codebases. The fat view puts orchestration, validation, business rules, emails, and payment calls all inside a view function, so the logic is bound to HTTP and cannot be reused or tested without a request. The fat model reacts by dumping everything onto the model instead, until a single class has fifty methods spanning unrelated concerns and every save risks a surprise side effect. Both scatter business logic where it is hard to find and harder to change. The goal of clean architecture is a single, obvious place for domain logic that does not depend on the web framework.

What clean architecture means here

Ignore the enterprise diagrams; for Django it comes down to one principle: separate the layers by responsibility and control the direction of dependencies. HTTP concerns live in views, domain logic lives in a service layer, and data access lives behind the ORM — and the domain logic does not reach up into HTTP. You are not adding ten abstraction layers; you are drawing a couple of clear lines so that "what the app does" is not tangled with "how it is delivered over the web".

The service layer

The core move is a service layer: plain functions (or modules) that encapsulate a business operation — create an order, cancel a subscription, transfer funds. A service takes primitives or domain objects, does the work in a transaction, and returns a result. It does not know about requests, responses, or status codes.

# services.py
def place_order(*, user, cart, address):
    with transaction.atomic():
        order = Order.objects.create(user=user, address=address, total=cart.total())
        order.items.bulk_create(cart.to_items(order))
        charge = payments.charge(user, cart.total(), idempotency_key=f"order-{order.id}")
        order.mark_paid(charge.id)
    email.send_order_confirmation(order)   # side effects after commit
    return order

Now the operation has one home. A view calls it, a management command calls it, a Celery task calls it, a test calls it — all with the same guarantees, none duplicating the logic.

Keeping logic out of views

With services in place, the view shrinks to what it should be: parse and validate input, call one service, translate the result into a response. It is the HTTP adapter, nothing more.

def checkout(request):
    form = CheckoutForm(request.POST)
    if not form.is_valid():
        return render(request, "checkout.html", {"form": form})
    order = place_order(user=request.user, cart=request.cart, address=form.cleaned_data["address"])
    return redirect("order_detail", pk=order.pk)

A view that is only orchestration is easy to read and rarely needs to change when business rules do — the rule change happens in the service, and every caller benefits at once.

Thin models, rich domain

Models should describe data and the small invariants intrinsic to a single record — a property, a simple derived value, a state-transition method that only touches its own fields. Cross-object workflows, orchestration, and rules that span several models belong in services, not on a model. The test for whether logic belongs on a model is simple: does it concern only this one object's own state? If it coordinates several objects or reaches into other systems, it is a service.

Selectors: separating reads from writes

Mirror services with selectors for read logic — functions that encapsulate a query, especially a complex or reused one. This keeps views and templates from growing ad-hoc querysets and gives read logic the same findability as write logic.

# selectors.py
def active_orders_for(user):
    return (Order.objects.filter(user=user, status="active")
            .select_related("address").prefetch_related("items"))

Separating the command side (services that change state) from the query side (selectors that read it) is a lightweight nod to CQRS that pays off in clarity long before you need anything heavier.

Domain versus framework boundaries

The deeper idea is that your domain logic should not depend on the delivery mechanism. Views, serializers, and templates are the web boundary; services and selectors are the domain. The domain can be exercised without spinning up a request, which is exactly what makes it testable and reusable. You do not need to abstract the ORM away entirely — that is usually over-engineering in Django — but you do want business rules that a Celery task or a CLI can invoke identically to a web request.

Dependency direction

Clean architecture is really a rule about which way dependencies point: outer layers (HTTP, tasks, CLI) depend on inner layers (services, domain), never the reverse. A service must never import a view or reach for request; if it needs the acting user or their locale, that is passed in as an argument. Keeping the arrows pointing inward is what lets you change the web layer — add an API alongside the HTML, swap a template for a SPA — without touching the domain at all.

Where validation lives

Validation splits by kind. Input validation — is this a well-formed email, is this field required — belongs in forms and serializers at the boundary. Business validation — can this user actually place this order, is there enough stock — belongs in the service, because it is a domain rule that must hold no matter which caller invokes it. Putting business rules only in a form means a management command or API that bypasses the form also bypasses the rule, which is how invariants get violated in the paths you forgot about.

Transactions in the service layer

The service is the natural transaction boundary: a business operation should be atomic, so wrap it in transaction.atomic() and let it commit or roll back as a unit. Crucially, defer side effects that cannot be rolled back — emails, webhooks, external charges you have already confirmed — until after commit, using transaction.on_commit, so a late failure does not send a confirmation for an order that never saved. Owning the transaction is one of the service layer's biggest wins over scattered logic that half-commits.

Testing a service layer

The real payoff is tests. Because a service is a plain function with explicit inputs, you test business logic directly — no client, no URLs, no templates — which is faster and far more focused than driving everything through the view.

def test_place_order_charges_and_marks_paid(db):
    order = place_order(user=user, cart=cart, address=addr)
    assert order.status == "paid"
    assert order.items.count() == 2

View tests then shrink to checking the HTTP adapter — right status, right redirect, form errors rendered — while the dense business-rule tests live against services where they are cheap to write and quick to run.

Pitfalls: over- and under-doing it

The two failure modes are opposite. Over-engineering imports enterprise patterns wholesale — repositories wrapping the ORM, interfaces for everything, a DTO for every model — adding indirection a Django app rarely needs and slowing everyone down. Under-doing it creates a service layer in name only, where services are anemic pass-throughs and the real logic still lives in views and models. Aim for the pragmatic middle: services and selectors as the home for real logic, thin views and models, and no abstraction you cannot justify by a concrete need.

DTOs and not leaking the ORM everywhere

A subtle source of coupling is passing ORM model instances everywhere, so that every layer — templates, serializers, external calls — depends on your database schema. For most Django apps this is fine and abstracting it away is over-engineering, but at the seams that matter you gain from a plain data transfer object: a small dataclass carrying exactly the fields a boundary needs, decoupled from the model. Returning a DTO from a service to an external integration means a schema change does not ripple into code that had no business knowing your columns. Use DTOs surgically at real boundaries, not as a blanket rule, and you get the decoupling without the ceremony.

Integrating external systems behind gateways

Payments, email, search, and third-party APIs are the parts of a system most likely to change and hardest to test if called inline. Put each behind a small gateway — a thin module exposing what your domain needs (payments.charge(...)) and hiding the vendor SDK behind it. Services depend on the gateway, not the vendor, so swapping Stripe for another processor, or faking payments in tests, touches one file instead of scattering vendor calls through your business logic. This is the one place the "depend on abstractions" rule earns its keep even in a modest Django app.

# gateways/payments.py — the domain calls this, not stripe directly
def charge(user, amount, *, idempotency_key):
    intent = stripe.PaymentIntent.create(
        amount=amount, currency="eur", customer=user.stripe_customer_id,
        idempotency_key=idempotency_key)
    return Charge(id=intent.id, status=intent.status)

Domain exceptions and error handling

Services should raise meaningful domain exceptionsInsufficientStock, PaymentDeclined — not return ambiguous None or leak framework errors. The boundary then maps each domain exception to the right HTTP response or user message, keeping the translation between "what went wrong in the domain" and "what the user sees" in one predictable place. This gives you precise, testable error paths: a test asserts the service raises PaymentDeclined, and separately a view test asserts that exception renders the right message, without either concern bleeding into the other.

Organizing the code

Structure follows the layers. A common Django-friendly layout gives each app a services.py (or a services/ package), a selectors.py, thin models.py and views.py, and a gateways/ package for external systems. The point is discoverability: a new developer knows business operations live in services, reads live in selectors, and the web layer is thin — so they can find and change a rule in seconds instead of grepping through fat views. Consistency across apps matters more than the exact filenames; pick a convention and hold to it.

Refactoring toward it incrementally

You do not rewrite a working app to adopt this — you migrate it one workflow at a time. Find the messiest, most-duplicated piece of logic, extract it into a service with tests, and point every caller at it. Repeat where the pain is real. Over a few months the important logic accretes into services and selectors while the rest of the app keeps working untouched. Incremental extraction driven by actual pain is how clean architecture arrives in a real codebase — not as a big-bang rewrite, but as steady, testable simplification of the parts that hurt.

When to adopt it

A small app or prototype does not need this — Django's defaults are productive and the ceremony would slow you down. Reach for a service layer when logic starts duplicating across views, tasks, and commands; when views grow past comfortable reading; or when tests require elaborate request setup to check a simple rule. Introduce it incrementally, extracting the messiest workflow into a service first and growing the pattern where it earns its place, rather than rewriting a working app into layers it may never need.