A Django checkout view for library loans has grown to 150 lines of availability rules, fee checks and emails; how would you make it thin?
answer
- HTTP concerns versus domain rules
- model methods for one object
- a plain function for several models
- domain exception, view picks response
basics
~20 sKeep only HTTP work in the view: read input, check access, call one domain function, choose the response. Move single-object rules into model or QuerySet methods and multi-model operations into a plain service function that takes no request and raises domain exceptions.
solid answer
~40 sI would split HTTP concerns from rules. The view keeps reading input through a form, access control via decorators, one call into the domain, and choosing the response. Rules about one object become model methods such as `Copy.is_available()`, reusable filters become custom `QuerySet` methods, and the checkout itself, which touches copies, members and loans, becomes a plain function like `checkout_copy(member=..., copy=...)` in `services.py`. That function takes model instances, never the `HttpRequest`, holds the transaction boundary, and raises a domain exception such as `CheckoutRefused` that the view maps to a message and redirect. The rules then get fast tests without the test client, and the admin action and API endpoint call the same code.
code
python · 20 linesfrom django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.http import require_POST
from catalog.models import Copy
from .services import CheckoutRefused, checkout_copy
@login_required
@require_POST
def checkout(request, copy_id):
copy = get_object_or_404(Copy, pk=copy_id)
try:
loan = checkout_copy(member=request.user, copy=copy)
except CheckoutRefused as exc:
messages.error(request, str(exc))
return redirect(copy)
messages.success(request, f"Due back on {loan.due_on:%d %b %Y}.")
return redirect(loan)go deeper
Recall that a view's job is HTTP in and out, and that rules like availability belong on the model or in a function the view calls.
Explain the split between model methods, QuerySet methods, forms and service functions, and why services take model instances rather than the request.
Refactor safely: pin behaviour with view tests first, move rules behind domain exceptions, keep transactions in the service, and avoid hiding side effects in signals.
Decide how much structure a codebase needs: a services module of plain functions is often enough, and heavier layering must pay for its indirection.
## The symptom A "fat" view is one where HTTP handling and business rules are interleaved. A library checkout view that has grown to 150 lines typically: - loads the copy and the member; - checks that the copy is not already on loan, that the member has no unpaid fees and has not hit the loan limit; - computes the due date from the member's category; - creates the `Loan`, updates counters, sends a confirmation email; - and finally chooses between a redirect and an error message. Every rule is only reachable through an HTTP request, so every test needs the test client, a signed-in user and a POST. The same rules are then re-implemented for the admin action that lends a copy at the desk and the management command that imports loans. ## What the view should own A **thin view** keeps the parts that are genuinely about HTTP: 1. **Reading input**: URL arguments, `request.POST` through a form, the signed-in user. 2. **Access control**: login and permission checks, usually as decorators. 3. **Calling the domain**: one call to a function or method that applies the rules. 4. **Choosing the response**: render, redirect, a message, or an error status. ## Where the rules go | Rule | A natural home | |---|---| | "Is this copy available?" | a model method, `Copy.is_available()` | | "Copies that can be lent" | a custom `QuerySet` method, `Copy.objects.available()` | | "Check out a copy for a member" (several models, one outcome) | a plain function in a `services.py` module | | "Is this form input well-formed?" | a `Form` or `ModelForm` | Model methods suit rules about **one object**. A service function suits operations that touch **several models** and must happen together; it is also where the transaction boundary belongs. Django has no built-in "service layer"; `services.py` is just a module of functions, which is its strength. ```python # loans/services.py from datetime import timedelta from django.db import transaction from django.utils import timezone from catalog.models import Copy from .models import Loan class CheckoutRefused(Exception): pass def checkout_copy(*, member, copy, days=21): with transaction.atomic(): copy = Copy.objects.select_for_update().get(pk=copy.pk) if not copy.is_available(): raise CheckoutRefused("This copy is already on loan.") if member.unpaid_fees() > 0: raise CheckoutRefused("Please settle unpaid fees first.") return Loan.objects.create( member=member, copy=copy, due_on=timezone.localdate() + timedelta(days=days) ) ``` The service takes plain arguments, **never the `HttpRequest`**, and signals refusal with a domain exception rather than an HTTP status. ## The view after the move The view shrinks to input, a call and a response choice. Because refusal is an exception, the view maps it to the user-facing outcome in one place: - `CheckoutRefused` becomes an error message and a redirect back to the copy's page; - success becomes a message and a redirect to the loan; - a missing copy is a 404 from `get_object_or_404()`, as before. ## Refactoring without breaking it 1. **Pin the behaviour first** with a few view tests through the test client: success redirect, each refusal message, the 404. 2. **Extract the rules unchanged** into model methods and one service function, and have the old view call them. 3. **Replace early returns with a domain exception** so the service reports refusals without knowing about HTTP. 4. **Shrink the view** to input, the call and the response mapping, keeping the pinned tests green. 5. **Add focused unit tests** for the service, one per rule, and drop view tests that now duplicate them. ## What you gain, and what to avoid Gains: - **Tests without HTTP.** `checkout_copy()` is tested with two model instances and no client, and each rule gets a focused test. - **One implementation.** The admin action, a management command and an API endpoint call the same function. - **Reviewable views.** A reader sees the whole HTTP story in a dozen lines. Pitfalls: - **Passing `request` into services.** It couples the rules to HTTP again and forces every other caller to fake a request. - **Hiding side effects in signals.** Moving the confirmation email into a `post_save` receiver makes the view thin by making the behaviour invisible; an explicit call is easier to follow. - **The god model.** Moving everything into `Member` or `Copy` methods just relocates the fat; operations spanning models read better as functions. - **Premature layers.** A thin view needs a function, not a framework of interfaces around the ORM.
- Why is moving a Django checkout's confirmation email into a post_save receiver a poor way to thin the view?It makes the view shorter by hiding behaviour: the email now fires on every `Loan` save the receiver does not filter out, including admin edits and fixture loads, and a reader of the checkout code cannot see it. An explicit call in the service function keeps the side effect where the operation is defined.
- How do you test the checkout rules once they live in a Django service function?Call `checkout_copy()` directly in a `TestCase` with model instances built for each case: an available copy, a copy already on loan, a member with unpaid fees. No test client, login or POST is needed. Keep a small number of view tests for the HTTP mapping: redirect targets, messages and the 404.
saying these in an interview costs you the question
- Business rules belong in the view because that is where the request is.
- A service function should take the HttpRequest so it can read the user.
- Moving side effects into signals is the cleanest way to thin a view.
- Every rule should become a method on the biggest model.
- Thin views require a class-based view hierarchy.