Business logic in Django and Django REST Framework applications

TLDR; As a general rule, no view function/class should contain any kind of business logic. By business logic, we mean any code that implements a feature or a story.

Introduction

Django and Django REST Framework provide a set of useful utilities and base classes that define a way we should write the code. Here, we will discuss how we can write view functions using Django and DRF while aiming a reasonably good code quality and maintability.

Think of business logic as if it is a library code that you use in your web or command line application.

Keeping logic in models

It is not a rule that the model is always a Django model. This can be a plain Python object that describes a business entity.

Model properties and methods

The model itself is a good place to put any logic that is related to this model. You can use properties and methods to keep the business logic within the model.

class User(models.Model):
    first_name = models.CharField()
    last_name = models.CharField()
    email_verified_at = models.DateTimeField(null=True)
    is_active = models.BooleanField()
    
    @property
    def full_name(self):
      return f'{self.first_name} {self.last_name}'
      
    @property
    def is_email_vefified(self):
       return self.email_verified_at is not None
       
    def confirm_email(self):
        self.email_verified_at = current_time()
        self.save()
        
    def activate(self):
        self.is_active = True
        self.save()
        
    def deactivate(self):
        self.is_active = False
        self.save()

The User model has all methods and properties to inspect and manage itself. When you open  the User model, you immediately see what you can do with it just by looking at its interface.

Don’t be afraid to have models that have a lot of methods. You always can group such methods and properties into a mixin class. This sometimes called model behaviors. More about models mixins you can find here.

Props and methods is a model interface. They declare what you can do with the model and give you a set of methods to fetch model instances in a convenient and discoverable way.

Avoid any side effects (like making HTTP calls, sending emails, etc.) in model methods. This is usually not a good idea, as it seems at the first sight.

Common ORM query filter aliases

Yes, filters are a part of business logic too. When business side tells you to list only paid users, this is a business rule.

Typically, it is a good practice to keep your filter queries as close to the base model as possible. Other developers can discover them via IDE’s autocomplete feature and reuse instead of reimplementing their own, blowing up the codebase with duplicates. There are two places where we can put this code: static methods of the model and custom model managers. We’d advise using static methods, as they don’t require a new migration and more discoverable by devs. A drawback of this approach is that Django’s documentation promotes custom model manages for this purpose, and new devs can be confused in the beginning.

class User(models.Model):
    first_name = models.CharField()
    last_name = models.CharField()
    email_verified_at = models.DateTimeField(null=True)
    is_active = models.BooleanField()
    
    @classmethod
    def find_only_active(cls):
        return cls.objects.filter(is_active=True)
        
    @classmethod
    def get_if_verified(cls, user_id):
        return cls.objects.filter(pk=user_id).exclude(email_verified_at__isnull=True).first()
        
active_users = User.find_only_active()

Sharing ORM filters via custom QuerySet methods

Additionally, you can extend model’s query set with custom QuerySet methods. This requires a custom model manager.

class UserQuerySet(models.QuerySet):
   def with_verified(self):
       return self.exclude(email_verified_at__is_null=True)

   def only_active(self):
       return self.filter(is_active=True)

class User(models.Model):
    first_name = models.CharField()
    last_name = models.CharField()
    email_verified_at = models.DateTimeField(null=True)
    is_active = models.BooleanField()
    
    objects = UserQuerySet.as_manager()
    
    @classmethod
    def get_active_verified_users(cls):
        return cls.objects.with_verified().only_active().all()
    
active_verified_users = User.objects.with_verified().only_active().all()
# OR (preferable)
active_verified_users = User.get_active_verified_users()

Prefer using custom query set methods in model filters.

Extracting business logic into service classes/functions

Not all code is that simple as a read/write operation on model. For cases, when the feature is too huge or does a lot of things, it needs to be put into a dedicated python module. An example of it can be the user registration where we not just create a new model, but send emails, create dependent objects, or make HTTP calls to third-party API.

Where to put such code (naming)

The name of the python file should tell us what is inside. If it contains user registration feature, then name it registration.py. If it provides export functionality, then export.py is a good name. A collection of Elasticsearch queries can live in es_queries.py.

Avoid names like services.py. The name doesn’t tell much about what is inside, and the file quickly gets abused by other devs. They tend to put everything into this file.

Find an example in “When your endpoint does a lot of work” section.

When to move my logic into a service function

  1. The function is a business rule (e.g., implements a story from task tracker).
  2. The function mutates app’s state (performs write operations)
  3. The function fetches data from non-Django ORM data source (remote API, Elasticsearch)
  4. The function does not belong to a single model. It may be modelless or work with multiple models.

When not to move

  1. If the function changes only one model, then you probably need a model method.
  2. If function only reads data from ORM, then go with ORM filter method.

Example

# comments/export.py

def export_comments_to_csv(limit, offset): ...
# comments/es_queries.py

def fetch_top_comments(limit, offset): ...
# comments/views.py

from .es_queries import fetch_top_comments
from .export import export_comments_to_csv

def query_top_comments_view(request):
    limit = request.query_params.get('limit', 10)
    offset = request.query_params.get('offset', 0)
    
    results = fetch_top_comments(limit, offset)
    return Response(results)
    
def export_view(request):
    limit = request.query_params.get('limit', 10)
    offset = request.query_params.get('offset', 0)
    
    csv_data = export_comments_to_csv(limit, offset)
    return Response(csv_data)

Handling business logic in endpoints

Do not pass request objects into your business logic functions. The request object must not leave the scope of the view function. Extract all required arguments from the request and pass them to your callable. See example below.

When your endpoint just reads one or many entities from the database

First, let’s talk about a very basic endpoint. When it just returns an object or set of objects, stick to DRF generic views or view sets and use serializers and filters. No further action required from your side.

class ProjectViewSet(ModelViewSet):
   filter_backends = ByOrganization, ByUser,
   serializer_class = ProjectSerializer
   queryset = Project.objects.all()
   permission_classes = CanManageProjects,

That’s it! This code snippet generates full CRUD interface with OpenAPI documentation.

When your endpoint has side effects, or you want to perform some action before or after the model’s modification

Sometimes your endpoint looks like a standard CRUD interface, but it wants to perform some extra action. For example, you want to send a welcome mail to the user when it gets created via POST request. In this case, we can override def perform_create(self, serializer) method and add a custom code before or after we create a user.

from .mails import send_welcome_mail

class UserViewSet(ModelViewSet):
   filter_backends = ByOrganization, 
   serializer_class = UserSerializer
   queryset = User.objects.all()
   permission_classes = CanManageUsers,
   
   def perform_create(self, serializer):
       user = serializer.save() # creates user
       send_welcome_mail(user)

There are two additional methods perform_update and perform_destroy for you.

When your endpoint does a lot of work

What to do if your logic is too complex and does not fit the tips above? We strictly recommend to not write any kind of feature related code in the view functions. This makes maintenance really hard, we make more complex integration tests, and, finally, such views become hard to read.

Instead, we advise putting such logic in some feature-related python module as a function or a class. Then, in the views.py just import and call that code.

# users/registration.py

@transaction.atomic
def register_user(first_name, last_name, email, password):
    user = User.objects.create(first_name=first_name, last_name=last_name, email=email)
    user.set_password(password)
    user.create_api_user()
    send_welcome_mail(user)
    return user
# users/views.py

from users.registration import register_user

class UserViewSet(ModelViewSet):
   queryset = User.objects.all()
   serializer_class = UserSerializer
   filter_backends = ByOrganization, 
   permission_classes = CanManageUsers,
   
   def perform_create(self, serializer):
       user = register_user(
          first_name=serializer.validated_data['first_name'],
          last_name=serializer.validated_data['last_name'],
          email=serializer.validated_data['email'],
          password=serializer.validated_data['password'],
       )
       # set user back to serializer to DRF can return it in the response
       serializer.instance = user

Non-model endpoints

Non-model views do not benefit from DRF’s sugar, but they have unlimited flexibility. They apply the same rules we discussed above.

def query_view(request):
    limit = request.query_params.get('limit', 10)
    offset = request.query_params.get('offset', 0)
    
    results = query_elasticsearch_for_data(limit, offset)
    return Response(results)

Prefer to always use serializers in responses. This makes sure that return type and response structure always stays the same. Think about API consumers.

References

  1. Django Model Behaviors
  2. Django model managers
  3. Business Logic in Django projects
  4. DOING DOMAIN DRIVEN DESIGN WITH DJANGO
Alex Oleshkevich

Alex Oleshkevich

Alex is a full-stack software developer with 15+ years of web development experience. His primary stack is Python, PHP, and JavaScript.
Minsk, Belarus