RizTech Academy logo
RizTech Academy
Architecture and Patterns in DjangoLesson 2 of 535 min

The service-layer debate: fat models versus services

The best-practices module said "fat models, thin views" and then admitted the slogan runs out when logic spans several models or external systems. That is where the service layer enters — and it is the most genuinely debated architecture question in the Django community. There is no single right answer, which is exactly why you need to understand the trade-off rather than follow a rule. This lesson lays out both sides honestly and gives you a way to decide.

The question

Where does a business operation that touches several models and maybe external systems live? "Complete an appointment": set its status, compute the fee, record a payment, charge the card via a gateway, send an SMS. Three positions compete:

  1. Fat models — put it on the most relevant model as a method (appointment.complete()).
  2. Service layer — put it in a separate function or module (services.complete_appointment(appt)) that orchestrates the models.
  3. In the view — the beginner default, already rejected: untestable, unreusable, and mixing HTTP with business logic.

Position 3 is settled (do not). The real debate is between 1 and 2, and both are defensible.

The case for fat models (the Django-idiomatic default)

Django's design leans towards fat models: the ORM is Active Record (data and behaviour together), and putting behaviour on the object it concerns is idiomatic. For most operations this is simplest and best:

class Appointment(models.Model):
    def complete(self):
        with transaction.atomic():
            self.status = self.Status.DONE
            self.fee = self._fee_for_patient()
            self.save()
            Payment.objects.create(appointment=self, amount=self.fee)

Advantages: it is discoverable (behaviour lives with the data), idiomatic (Django expects it), and needs no extra layer. For a clinic app of moderate size, fat models handle the large majority of operations cleanly. Fat models are the right default — reach for a service layer only when a model method starts to strain.

The case for a service layer

Fat models strain in specific, recognisable ways, and that is when a service layer earns its place:

# services.py — a plain function orchestrating an operation
def complete_appointment(appointment, *, charge_card=True):
    with transaction.atomic():
        appointment.status = Appointment.Status.DONE
        appointment.fee = calculate_fee(appointment)
        appointment.save()
        payment = Payment.objects.create(appointment=appointment, amount=appointment.fee)
    if charge_card:
        payment_gateway.charge(payment)          # external system — NOT on the model
    sms.send_receipt(appointment.patient)         # external system
    return payment

The service layer is justified when an operation:

  • Spans several models such that no single model is the obvious home (which model owns "complete appointment + payment + SMS"?).
  • Coordinates external systems — a payment gateway, an SMS provider, a lab API. Putting these on a model couples your data model to third-party services and makes it hard to test.
  • Represents a process, not an object's behaviour — a workflow with several steps that does not belong to any one entity.
  • Would make a model a "god object" — a model accreting dozens of methods for operations only loosely about it.

A service is just a function (Python does not need a Service class — that is the Java transliteration the previous lesson warned against). It keeps the model focused on being an appointment and the process of completing one in a clearly-named function that views, commands, tasks and tests all call.

The honest debate, and the trap

Here is the genuinely contested part, stated fairly:

  • The "always use a service layer" camp (influential in some Django circles) argues that business logic should never be on models or in views — always in services — for consistency and testability. It produces a very clear structure in large teams.
  • The "fat models, services only when needed" camp (closer to idiomatic Django) argues that a service layer everywhere is over-engineering for most projects: it adds a layer of indirection and anaemic models (models reduced to bare fields with no behaviour), which throws away what the ORM gives you.

The trap to avoid is cargo-culting either extreme. Putting every one-line operation in a service is ceremony; refusing a service for a genuinely cross-cutting, external-system-touching process makes a model a coupled mess. The mature position: fat models by default; a service function when an operation genuinely outgrows a single model or reaches an external system. Let the code tell you — when a model method starts importing a payment gateway or coordinating three models, that is the signal to extract a service.

How to decide, in practice

A short procedure for any operation:

  1. Is it the behaviour of one object, using only your own models? → a model method (fat model).
  2. Does it touch an external system, or coordinate several models as a process? → a service function.
  3. Is it just query logic (selecting rows)? → a manager/queryset method (next lesson), not either of the above.
  4. Whichever you choose, keep it out of the view — the view calls it.

For Nidaan: patient.age() is a model method; appointment.complete() might start as a model method and become services.complete_appointment() once it charges a card and sends an SMS; Appointment.objects.booked() is a manager method. The point is not a dogma but a fit — put each piece of logic where it naturally belongs, default to the model, and extract a service when the model is no longer the honest home.

Check your work

The question. Where a multi-model/external-system operation lives — fat model, service layer, or (never) the view.

Fat models (default). Behaviour of one object, on that object; idiomatic to Django's Active Record ORM; handles most operations — the right default.

Service layer (when needed). A plain function orchestrating an operation that spans several models, touches external systems, is a process rather than an object's behaviour, or would make a model a god object. It is a function, not a Service class.

The honest debate. "Always services" (consistency, but over-engineering + anaemic models for most apps) versus "fat models, services when needed" (idiomatic). Avoid cargo-culting either extreme.

How to decide. One object + own models → model method; external system / multi-model process → service function; selecting rows → manager method; always keep it out of the view.

Practice

  1. Take appointment.complete() as a model method; then extend it to charge a card and send an SMS, and argue at what point it should become a service function.
  2. Write the service-function version and note it is a plain function, not a class — reason about why a Service class would be un-Pythonic.
  3. Classify five Nidaan operations as model method / service function / manager method, and justify each.
  4. Find (or imagine) a model that has become a god object; identify which methods should move to services.
  5. Argue both sides of the "always use a service layer" debate in a paragraph each, then state your position for a moderate-sized clinic app.
  6. Take an operation currently in a view and decide, using the four-step procedure, where it should go.

Official documentation

Next: custom managers as the repository pattern.

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