RizTech Academy logo
RizTech Academy
Writing Django Worth ReadingLesson 1 of 530 min

Fat models, thin views: where business logic belongs

The most-repeated piece of Django advice is "fat models, thin views" — and like most slogans it is right in spirit and misleading if taken literally. Where your business logic lives determines whether a codebase stays testable and navigable or turns into a tangle. This lesson is the honest version: what belongs in a view, what belongs on a model, why "thin views" matters, and where the slogan runs out (which the architecture module then picks up).

The problem: logic in the view

The instinct of a beginner is to put everything in the view — it is where the request arrives, so it feels natural to do the work there:

def complete_appointment(request, appointment_id):
    appt = get_object_or_404(Appointment, id=appointment_id)
    # business logic crammed into the view:
    appt.status = "done"
    appt.fee = appt.doctor.standard_fee
    if appt.patient.appointments.filter(status="done").count() >= 5:
        appt.fee = appt.fee * Decimal("0.9")     # loyalty discount
    appt.save()
    Payment.objects.create(appointment=appt, amount=appt.fee)
    messages.success(request, "Appointment completed.")
    return redirect("appointment_list")

The logic — how completing an appointment sets its status, computes the fee, applies a loyalty discount, records a payment — is business rules, and they are stuck inside a view. Three costs follow: the rules cannot be reused (a management command or an API endpoint that completes an appointment must copy them); they cannot be tested without simulating an HTTP request; and the view is now long and does two unrelated jobs (HTTP handling and business logic).

Thin views: a view's real job

A view's job is HTTP plumbing, not business logic: read the request, call the logic, turn the result into a response. That is it. The completed-appointment view should read:

def complete_appointment(request, appointment_id):
    appt = get_object_or_404(Appointment, id=appointment_id)
    appt.complete()                              # the business logic lives elsewhere
    messages.success(request, "Appointment completed.")
    return redirect("appointment_list")

The view now does only what a view should: fetch, delegate, respond. It is short, obvious, and easy to test at the HTTP level (does it redirect? does it require login?). The rules moved out — and where they go is the question.

Fat models: logic on the object it concerns

The "fat models" half says: put business logic on the model it is about, as a method. Completing an appointment is behaviour of an appointment, so it belongs on Appointment:

class Appointment(models.Model):
    # ... fields ...

    def complete(self):
        with transaction.atomic():                        # related writes, so atomic
            self.status = self.Status.DONE
            self.fee = self._fee_for_patient()
            self.save()
            Payment.objects.create(appointment=self, amount=self.fee)

    def _fee_for_patient(self):
        base = self.doctor.standard_fee
        if self.patient.appointments.filter(status=self.Status.DONE).count() >= 5:
            return base * Decimal("0.9")                  # loyalty discount
        return base

Now the rule lives with the thing it describes. It is reusable (a view, a command, an API, a test all call appt.complete()), testable (create an appointment, call complete(), assert the result — no HTTP needed, exactly the model tests from the testing module), and discoverable (someone asking "what happens when an appointment completes?" looks at Appointment.complete). Query logic likewise belongs on the model's manager/queryset (the custom-managers lesson) — Appointment.objects.booked(), not filter(status="booked") scattered through views. Model = data and the behaviour of that data.

Where "fat models" runs out

Here is the honest caveat the slogan omits: not all logic belongs on a model, and models can get too fat. Logic that spans several models, coordinates external systems, or represents a process rather than the behaviour of one object does not have an obvious model home. "Complete an appointment, charge the card via the payment gateway, send an SMS, and update the doctor's schedule" touches four things and calls an external service — cramming that onto Appointment makes the model a dumping ground and couples it to SMS and payment providers.

For that, many teams introduce a service layer — plain functions or modules that orchestrate a multi-model operation — which the architecture module examines in full (including the honest debate about whether Django needs it). The rule for now: behaviour of a single object goes on that object (fat model); a process that coordinates several objects or external systems may warrant a service function. "Fat models" is the right default; "everything on the model" is not.

The principle beneath the slogan

Strip away the slogan and the real principle is: keep each layer doing its own job. Views handle HTTP. Models hold data and the behaviour of that data. Templates present. Managers name queries. Business processes that do not fit a single model live in a clearly-named function. When logic is in the layer it belongs to, the codebase is testable (each layer tested in isolation), reusable (logic is not trapped in a view), and navigable (you know where to look). "Fat models, thin views" is a memorable shorthand for "business logic out of views, onto the object it concerns" — follow that, and reach for a service function when an operation genuinely outgrows a single model.

Check your work

The problem with logic in views. Business rules stuck in a view cannot be reused, cannot be tested without HTTP, and make the view long and double-purpose.

A thin view's job. HTTP plumbing only — read the request, call the logic, return a response; short and easy to test at the HTTP level.

Fat models. Put a single object's behaviour on that object as a method (appt.complete()) — reusable, testable without HTTP, discoverable; query logic goes on the manager.

Where it runs out. Logic spanning several models or external systems, or representing a process, has no single model home — cramming it on one model makes it a dumping ground and couples it.

The service layer (preview). A clearly-named function/module orchestrating multi-model operations — the architecture module debates it; for now, single-object behaviour on the model, multi-object processes in a function.

The real principle. Keep each layer doing its own job — views HTTP, models data+behaviour, templates presentation, managers queries — for testability, reuse and navigability.

Practice

  1. Take a view with business logic crammed in (like the first example) and move the logic to a model method; reduce the view to fetch-delegate-respond.
  2. Write a model test for that method (no HTTP), then a thin view test (redirect/login) — note each layer is tested separately.
  3. Call the new model method from a second place (a management command or shell) to prove the logic is now reusable.
  4. Find a filter(...) repeated across views and move it to a manager method; update the views to call it.
  5. Identify an operation in Nidaan that spans several models and an external system; argue why it does not belong on a single model.
  6. Write one sentence each on what belongs in a view, a model, a manager, and (potentially) a service function.

Official documentation

Next: settings, environments and secrets, done right.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship