Roman Imankulov

Roman Imankulov

Full-stack Python web developer from Porto

search results (esc to close)
06 Apr 2023

From Django class-based views to service functions

Django Views and Service Functions
Photo by Valery Fedotov

If you use Django class-based views (CBV), do you feel like it takes significant mental effort to wrap your hand around the logic spread across various mixins? I have never been a big fan of Django’s class-based views (CBV) and their mixin approach. In my experience, classical function-based views (FBV) work as good as CBV, but provide more readable code.

What are the problems of class-based views

The logic stops being linear. Instead of running the code sequentially as written, the execution flow jumps between different small functions and classes in seemingly random order. Some classes belong to your code, while others are provided by the framework. Some methods extend methods of superclasses and call super() forcing you to go down deeper the rabbit hole. Serving a noble purpose of code de-duplication, class-based views often offer the cure that is worse than the disease. The code gets messy in an attempt to save or reuse a few lines of code.

Tight coupling between views. Subclasses share the code with their parents. In that sense, they start depending on the parent class. Now you can only change the behavior of your parent class or a mixin if you consider all the views that inherit this behavior simultaneously. You unintentionally create a dependency between your views. On that topic, I recommend a 2016 blog post by Sandi Metz The Wrong Abstraction where the author coined the rule of thumb duplication is far cheaper than the wrong abstraction.

Class-based views hint that you may use them for business logic. Writing business logic in views is a bad idea, yet class-based views make it tempting because it feels like you can easily organize your code there. Class-based views also suggest extending the class hierarchy by creating more base classes and subclasses.

There is a better solution that writing business logic in views. A common approach is to move it to so-called service functions (I will talk about them later).

If you need complex business logic, create a class or a few but you may want to get away without mixins there as well. Another rule of thumb is prefer composition over inheritance .

Class-based views make you write more complicated business logic than you would write otherwise

Solutions

Service functions. Regardless of the framework, I find it helpful to have a service layer: functions and classes responsible for business logic.

The service layer is standard in modern Django architecture. For example, you can find how to organize it, in this Django styleguide.

Remark: Not everyone agrees that the service layer is a good idea. A polemical blog post Against service layers in Django argues against the use of service layer, suggesting that models and model managers provider enough functionality to express necessary business logic. Still, the author doesn’t propose storing the business layer in views.

Decoupling. Service layers depend less on framework-specific things like request objects in views or authentication details. If necessary, the service layer can be totally framework-agnostic, although I wouldn’t recommend doing with Django this unless you have solid reasons.

Decoupling to service functions simplifies the testing of business logic and makes it easier to use service functions in other places (for example, in management commands, cron jobs, etc.) Service functions can be extracted in a separate Python package and used elsewhere if necessary.

If you ever decide to migrate away from Django to a different framework, an isolated enough service layer can help you with this endeavor.

Role of views. With a service layer, the view becomes a thin shell around one or a few service functions. It aims to validate input, check privileges, and pass processing down.

Example

To make it more concrete, let’s consider a hypothetical example of a class-based view that takes form data to create a new Contact() object and send an email to the admin.

Before

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.edit import FormView
from django.core.mail import send_mail
from .forms import ContactForm
from .models import Contact


class ContactView(LoginRequiredMixin, FormView):
    form_class = ContactForm
    template_name = 'contact.html'
    success_url = '/contact/'

    def form_valid(self, form):
        # Send email with submitted data
        name = form.cleaned_data['name']
        email = form.cleaned_data['email']
        message = form.cleaned_data['message']
        send_mail(
            f'New Contact Request from {name}',
            f'Name: {name}\nEmail: {email}\nMessage: {message}',
            'admin@example.com',
            ['admin@example.com'],
            fail_silently=False,
        )
        # Save contact data to database
        contact = Contact(name=name, email=email, message=message)
        contact.save()
        return super().form_valid(form)

In this example, we are extending the FormView class to provide a view for handling contact form submissions. We require the user to be logged in using the LoginRequiredMixin, so only authenticated users can submit the contact form. If the email is sent successfully, we call the parent class’s form_valid method to redirect the user to the success_url.

After

Here is how I would write the same functionality using regular views and services. I use Pydantic to create a data transfer object to pass the content from a view to a service function.

# file: views.py
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from .forms import ContactForm
from .services import handle_contact_form, ContactFormData
from .models import Contact

@login_required
def contact_view(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)
        if form.is_valid():
            # Convert form data to Pydantic DTO object
            form_data = ContactFormData.from_orm(form.cleaned_data)
            # Call service function to handle contact form submission
            handle_contact_form(form_data)
            return redirect('contact_success')
    else:
        form = ContactForm()
    return render(request, 'contact.html', {'form': form})
# file: services.py

from pydantic import BaseModel
from .models import Contact

class ContactFormData(BaseModel):
    name: str
    email: str
    message: str

def handle_contact_form(form_data: ContactFormData):
    # Send email with submitted data
    name = form_data.name
    email = form_data.email
    message = form_data.message
    send_mail(
        f'New Contact Request from {name}',
        f'Name: {name}\nEmail: {email}\nMessage: {message}',
        'admin@example.com',
        ['admin@example.com'],
        fail_silently=False,
    )
    # Save contact data to database
    contact = Contact(name=name, email=email, message=message)
    contact.save()

A few notes on the code.

  • Data transfer object (DTO) may be overkill for this case. With only three parameters, view can pass them directly to the service function without wrapping them into an object.
  • Pydantic may not be necessary either. If you need a DTO and don’t want to introduce an extra dependency, you can use Django Forms or dataclasses. I am not a big fan of Django forms for this purpose, as they collect too many responsibilities for a simple DTO object: from data conversion and validation to rendering HTML.
Roman Imankulov

Hey, I am Roman, and you can hire me.

I am a full-stack Python web developer who loves helping startups and small teams turn their ideas into products.

More about me and my skills