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
- The function is a business rule (e.g., implements a story from task tracker).
- The function mutates app’s state (performs write operations)
- The function fetches data from non-Django ORM data source (remote API, Elasticsearch)
- The function does not belong to a single model. It may be modelless or work with multiple models.
When not to move
- If the function changes only one model, then you probably need a model method.
- 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.