Skip to content

Views

This is the most prescriptive section of the whole book. If you've got this far, you've hopefully already read (and been persuaded by!) the arguments I've made so far about simplicity, code organisation and API design. This chapter is where all these ideas come together: it shows you how to actually build the views that the rest of the application revolves around, and connect the data models and business logic patterns we've already discussed to the endpoints that serve frontend clients.

As discussed in the section on REST, I advocate creating only two kinds of endpoint: those that respond to GET and retrieve data from the database, and those that respond to POST and change state on the server (or, more generally, perform an action). The implementation patterns for each of these are quite different.

GET endpoints

For endpoints that respond to GET, the overriding goal is to be able to quickly and efficiently stand up endpoints to meet frontend use cases. The implementation of these endpoints should clearly express the data shape returned by the server, and should be efficient by default, avoiding N+1 query issues by design.

Generally, the shape of the data returned by these endpoints should closely mirror the relationship structure of the underlying models, with minimal restructuring and aliasing of names. This reduces cognitive load, because it helps frontend developers understand the backend better and therefore makes communication across the team more efficient.

GET endpoints usually fall into one of two categories: those that return data for a single object ("retrieve" or "detail" views) and those that return data for multiple objects of the same type ("list" views)1.

If you haven't already, I'd highly recommend familiarising yourself with the django-readers documentation before reading this section. For brevity, I've excluded permissions, authentication, pagination, filtering and so on from the examples below. To add these features, you can just use the DRF functionality you're already accustomed to, alongside the brilliant django-filter.

Detail endpoints

To build detail endpoints, we can lean on Django REST framework's built-in RetrieveAPIView. By adding a SpecMixin from django-readers, we can avoid the need to create a serializer entirely and simply declare the data shape we want.

class BookDetailView(SpecMixin, RetrieveAPIView):
    queryset = models.Book.objects.all()
    spec = [
        "id",
        "title",
        "publication_date",
        {"author": [
            "id",
            "first_name",
            "last_name",
        ]},
    ]

List endpoints

The pattern here is almost identical: we use a SpecMixin in combination with a ListAPIView.

class PublisherListView(SpecMixin, ListAPIView):
    queryset = models.Publisher.objects.all()
    spec = [
        "id",
        "name",
        {"published_book_count": pairs.count("book")},
    ]

POST endpoints

Endpoints that respond to POST should generally follow the same rough pattern: validate incoming data, call an action function, and return a minimal response.

The first mental trap to escape from is the idea that a serializer should be thought of as a way to handle incoming data that is then used to create or update a model instance. This way of thinking is encouraged by the existence of ModelSerializer, a high-level construct that dynamically creates a serializer based on a subset of the fields on a model. While this approach works for simple cases, it's far more helpful to think about validation in a different way: a serializer is used to validate the data provided to an endpoint before that data is passed to an action function.

This may feel like a trivial distinction, and in simple CRUD systems it may be. But in more complex real-world endpoints that involve handling somewhat complex or nested data, developers often jump through hoops to map the shape of data onto the model structure via the serializer. This is often not necessary: design the payload in the simplest and most sensible way that makes it easy for the frontend to construct and easy for the backend to validate, and then map it onto the model layer in an action function.

Another bit of psychological baggage that comes along with the notion that serializers should map directly to models is the idea that serializers should be reusable. If you use a ModelSerializer as the base class for a WidgetSerializer in a serializers.py somewhere in your codebase, you reinforce the idea that the WidgetSerializer is the single place where Widgets should be serialized, deserialized and validated. This approach is used throughout the DRF documentation, and is so deeply ingrained in the structures of the higher-level DRF concepts that it's difficult to question.

In fact, in a real-world application, different subsets of the fields on a Widget may be needed in different parts of the application (hence doing away with serializers entirely for read endpoints in favour of using a spec). Similarly, the process of creating or updating a Widget may involve specific fields, or derived data, or the creation of multiple related objects in a single unit. It's far better to have a one-to-one mapping between serializers and POST endpoints than it is to have a one-to-one mapping between serializers and models.

For this reason, we use a serializer class defined inline in the view. This idea roughly converges with the approach suggested in the Hacksoft styleguide (at least where POST endpoints are concerned) and so I suggest that you follow their recommendation of always using InputSerializer as the class name for the serializer.

Where the approaches diverge slightly is that their guide suggests jumping through some fairly complicated hoops to handle the case that an action function (they call it a "service") can raise a ValidationError. I would suggest a far simpler pattern: ValidationError should only ever be raised from view code (as a result of a validation error). If an exception is raised from inside an action function, that is considered an unhandled application error and results in a 500 error response being sent to the client. This pattern doesn't cover all possible cases, but in my experience it's comprehensive enough to avoid the need for custom exception handlers. If you do genuinely need to do some validation inside an action, just catch an exception in the calling view code and "upgrade" it to a ValidationError. That should be uncommon enough in most applications that the boilerplate doesn't become a burden.

By following this approach, views become minimal enough that using higher-level DRF concepts becomes superfluous. Writing out the flow control is boilerplate, but it's minimal enough that the benefits of clear and simple readability outweigh the small drawback of some repetitive code. This goes back to the suggestion of "keeping the view viewable" discussed in the section on programming style.

Finally, POST views should generally not return a representation of the object that has been created or updated as a result of their action. In fact, whenever possible they should return nothing: an empty payload with just a helpful status code. This is largely for reasons of consistency (not every view creates or updates an object at all, so why have some return a representation and others not) and the principle of single responsibility (view implementations are far simpler if they can concern themselves with validating data and calling actions, and avoid entirely the complex business of serializing objects). This does often create an extra request from the frontend to the backend to reload an object after it's been updated, but the overhead here is so minimal as not to be worth worrying about. If it is essential to return some data, such as the generated ID of an object that has been created, just include that value when constructing the Response object to return from the view.

from project.actions.authors import create_author


class CreateAuthorView(APIView):
    class InputSerializer(serializers.Serializer):
        name = serializers.CharField()
        nom_de_plume = serializers.CharField(required=False)
        date_of_birth = serializers.DateField()

    def post(self, request):
        serializer = self.InputSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        author = create_author(
            name=serializer.validated_data["name"],
            nom_de_plume=serializer.validated_data["nom_de_plume"],
            date_of_birth=serializer.validated_data["date_of_birth"],
        )

        return Response({"id": author.id}, status=201)

Code structure and URLs

We've already touched on the idea of keeping interface code (views and URLs) separate to models and business logic. The simplest way to organise your view code is for your hierarchy of Python packages to mirror your hierarchy of URL segments.

Within each API surface, structure URLs so that each segment corresponds to:

  • A package segment under project.interfaces.web.api, and
  • An include() entry in the parent urls.py.

For example, to serve URLs under /api/admin/widgets/ from the HTTP API, use this package layout:

project/interfaces/http/urls.py
project/interfaces/http/api/urls.py
project/interfaces/http/api/admin/urls.py
project/interfaces/http/api/admin/widgets/urls.py
project/interfaces/http/api/admin/widgets/views.py

With corresponding URL wiring:

  • project/interfaces/http/urls.py:
    • path("api/", include("project.interfaces.http.api.urls"))
  • project/interfaces/http/api/urls.py:
    • path("admin/", include("project.interfaces.http.api.admin.urls"))
  • project/interfaces/http/api/admin/urls.py:
    • path("widgets/", include("project.interfaces.http.api.admin.widgets.urls"))
  • project/interfaces/http/api/admin/widgets/urls.py:
    • Leaf path() calls for individual views.

Each urls.py at an intermediate level only delegates further with include(). Leaf urls.py files are the only place that bind concrete views to concrete paths (unless it makes sense to also have views at intermediate levels of the tree).

This pattern keeps the URL hierarchy discoverable: given a path like /api/admin/widgets/create/, it is straightforward to find the corresponding urls.py and views.py under project.interfaces.http.api.admin.widgets.

For predictable URL reversing, set app_name in every urls.py to match its last path segment (for example app_name = "admin" or app_name = "widgets") and use namespaced reverses such as reverse("admin:widgets:create").

Schemas

It can often be extremely useful to have the ability to automatically generate an OpenAPI schema from your view definitions, to support documentation for your API, or even to convert into TypeScript types to be consumed by your frontend. The aptly named drf-spectacular library is extremely good at generating such schemas by deeply introspecting the views and serializers in a "traditional" DRF-based application.

The implementation is left as an exercise for the reader, but by using a combination of the serializer generation support in django-readers, the convention of using InputSerializer as the standard name for all POST views, and a custom AutoSchema, it's straightforward to add such automatic schema generation to a codebase that follows the view patterns recommended here.

Summary

Bringing together all of the structural and conceptual ideas discussed so far, some straightforward and lightweight patterns emerge for declarative GET endpoints and clear, explicit POST endpoints that tend to result in extremely readable, maintainable view code.


  1. There is a third category that simply covers any endpoint that doesn't fall into either of the first two, such as endpoints that return aggregated data across multiple models or specific computed values to power something like a dashboard page. In this case, just use an APIView, implement the get method, and return a Response, with a dictionary containing the data in whatever shape you need. In most applications, this style of view will be uncommon.