RizTech Academy logo
RizTech Academy
Architecture and Patterns in DjangoLesson 3 of 530 min

Custom managers as the repository pattern

The repository pattern is a staple of enterprise architecture: a dedicated object that knows how to fetch and store your domain objects, so the rest of the code asks the repository rather than writing queries inline. In many languages you build a repository class by hand. In Django you (mostly) do not need to, because the custom manager/queryset you already met is the repository pattern, done the idiomatic way. This lesson connects the two and shows why a hand-built repository class over the ORM is usually the wrong call.

What the repository pattern is, and what it is for

The repository pattern centralises data access: instead of scattering filter(status="booked") across views, one place owns "how to fetch booked appointments", and callers ask it. The goals are centralisation (query logic in one spot), naming (booked() instead of a raw filter), and decoupling (callers do not know the query details). These are good goals — the best-practices module's "query logic on the manager, not scattered in views" is exactly this.

Django already gives you the repository — the manager

You built this in the ORM-in-depth module. A custom queryset with named methods, attached as the manager, is a repository:

class AppointmentQuerySet(models.QuerySet):
    def booked(self):
        return self.filter(status=Appointment.Status.BOOKED)

    def for_city(self, city):
        return self.filter(patient__city=city)

    def visible_to(self, user):
        if user.is_staff:
            return self
        return self.filter(patient__user=user)


class Appointment(models.Model):
    objects = AppointmentQuerySet.as_manager()

Appointment.objects.booked(), .for_city("Pune"), .visible_to(user) — these are repository methods. Query logic is centralised on the manager, each query has a name, and callers (views, services, tasks) ask the manager without knowing the filter details. Verified earlier: .booked() returns exactly the booked appointments, and the methods chain (booked().for_city("Pune")). You have the repository pattern's benefits with none of its ceremony, because Django's manager is the repository, integrated with the ORM.

Why a hand-built repository class is usually wrong in Django

In a Java or "clean architecture" codebase you might write a AppointmentRepository class wrapping the ORM:

# the transliterated Java approach — usually WRONG in Django
class AppointmentRepository:
    def get_booked(self):
        return Appointment.objects.filter(status="booked")
    def get_for_city(self, city):
        return Appointment.objects.filter(patient__city=city)
    def save(self, appointment):
        appointment.save()

This looks disciplined and is, in Django, almost pure overhead. Consider what it costs and gives:

  • It duplicates the manager — you now have two places that hold query logic (the repository and the ORM it wraps), and the repository just forwards to the ORM.
  • It loses chaining — get_booked() returns a queryset, but composing "booked in Pune" means adding another method, where a queryset method would just chain.
  • It wraps Active Record in a Repository — two patterns fighting; Django's ORM is already the data-access layer, and a repository over it re-implements what it already does.
  • Its usual justification — "so I can swap the database/ORM" — is a hypothetical that almost never happens, and Django's ORM already abstracts the database (PostgreSQL, MySQL, SQLite) behind the same API.

So the hand-built repository class solves a problem Django already solved, at the cost of an extra layer and lost chaining. The manager is the repository; do not build another one on top. This is the clearest example of the module's warning: a genuinely useful pattern (repository) is already provided, and importing the Java form of it makes the code worse.

When something more than a manager is warranted

Honesty about the edges: there are rare cases where a separate data-access function helps, and they are the same edges as the service layer:

  • A query that spans models in a way that does not belong to one model's manager — a cross-cutting report joining appointments, payments and patients might live in a service or a dedicated query function rather than being forced onto one model's manager.
  • Caching or an external data source — if "get the patient's record" sometimes comes from a cache or an external system, a small function that encapsulates where the data comes from is reasonable (this is the legitimate kernel of the repository idea).

But these are functions written when the need is felt, not a Repository class layered over every model speculatively. The rule mirrors the service-layer rule: manager/queryset methods by default; a separate query function only when the query genuinely does not belong on a single model's manager.

Check your work

The repository pattern's goals. Centralise data access, name queries, decouple callers from query details — all good goals.

Django's built-in repository. A custom queryset with named methods attached via .as_manager() is the repository — Appointment.objects.booked() centralises, names, and decouples; verified to work and chain.

Why a hand-built repository class is wrong here. It duplicates the manager, loses chaining, wraps Active Record in Repository (two patterns fighting), and its "swap the ORM" justification is a hypothetical the ORM already covers.

The rare exceptions. A cross-model query that fits no single manager, or encapsulating an external/cached data source — a function written when needed, not a speculative Repository class per model.

The rule. Manager/queryset methods by default; a separate query function only when the query does not belong on one model's manager.

Practice

  1. Take three filter(...) calls scattered in views and centralise them as named queryset methods on a manager; confirm they chain.
  2. Write the hand-built AppointmentRepository class version and list, concretely, what it costs versus the manager (duplication, lost chaining).
  3. Add a visible_to(user) queryset method (the object-level scoping from the auth module) and use it in a view — a repository method that encapsulates an access rule.
  4. Argue why "we might swap the ORM one day" does not justify a repository layer in a Django project.
  5. Find a cross-model report and decide whether it belongs on a manager, a service function, or a dedicated query function — and why.
  6. Explain, in two sentences, why Django's manager is the repository pattern.

Official documentation

Next: signals, and when not to reach for them.

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