Have you ever been in a stage where adding one single feature to your API would crash your entire system? I’m pretty sure that your serializers and models must have driven you crazy! You should’ve understood by this point that you’ve written code that does not scale. This is why it is important to use a code style while building your applications. Here are some of the good practices that you can follow when building your next Django application.
#1: 3-Layer Structure
A scalable Django Rest application should have 3 layers: Views, Selectors & Services, Models
Views: A HTTP request to the API would call the corresponding view function.
Selectors: Whenever the view requires some data from the database, its corresponding selector function is called.
Services: Whenever the view requires to change or add some data to the database, the corresponding service function is called.
Models: Selectors and Services use Models to query the database.
Views cannot directly use the models and access the database; It has to use the selectors and services to do so.
#2: Rule of Business Logic
Business Logic is the rules that handle what data is returned from or added to the database. We usually divide this between serializers, views, models, and model managers. This is the worst thing you could do to your code.
Business Logic must be contained within Selectors & Services. Never write your Business Logic inside Views or Serializers.
An example of a service that creates a user is:
# Service
def user_create(*, email: str, name: str) -> User:
user = User(email=email)
user.full_clean()
user.save()
profile_create(user=user, name=name)
confirmation_email_send(user=user)
return user
Services should only have named arguments; that is why it has a * as the first parameter. The same goes for selectors.
# Selector
def user_list(*, fetched_by: User) -> Iterable[User]:
user_ids = user_get_visible_for(user=fetched_by)
query = Q(id__in=user_ids)
return User.objects.filter(query)
#3: Never Use ViewSets
This is one point that I cannot stress enough, to never use a ViewSet. Each individual API endpoint must have its own view function defined. ViewSets is meant only to be used for quick development of API and can cause mishaps in long run.
Each view must be a subclass of APIView class from DjangoRestFramework.
For example, every ViewSet defines the four most important views: retrieve, update, create, and destroy. We will split this ViewSet into four individual views: DetailView, UpdateView, CreateView, and DeleteView, each of which is a subclass of APIView.
#4: Squash Views & Serializers
It is common to write views and serializers in separate files. We usually create Model Serializers for each model.
The problem with this approach is that, in the Model Serializer, we define what attributes should be returned when using it to query the database. But sometimes we might need a different set of data from the serializer. What we often do in such situations is to create multiple serializers of the same model to satisfy the needs. This gets harder to manage as code grows.
So to avoid such situations, we will define the serializer for each individual view.
In effect, we will have two types of serializers, InputSerializers, and OutputSerializers.
InputSerializer should be a subclass of the Serializer class and every attribute must be explicitly defined. Whereas Output serializer can be a subclass of ModelSerializer.
class CourseDetailView(AuthMixin, APIView):
class OutputSerializer(serializers.ModelSerializer):
class Meta:
model = Course
fields = ('id', 'name', 'start_date', 'end_date')
def get(self, request, course_id):
course = get_course(id=course_id)
serializer = self.OutputSerializer(course)
return Response(serializer.data)
class CourseCreateView(AuthMixin, APIView):
class InputSerializer(serializers.Serializer):
name = serializers.CharField()
start_date = serializers.DateField()
end_date = serializers.DateField()
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
create_course(**serializer.validated_data)
return Response(status=status.HTTP_201_CREATED)
#5: Validations in Model
Validations are a part of the business logic. Suppose that we are creating a university application, and we must validate if the start_date of course should never be greater than the end_date. We should validate this every time a course is added.
Such simple validation can be done in a method called clean provided by the model class.
class Course(BaseModel):
name = models.CharField(unique=True, max_length=255)
start_date = models.DateField()
end_date = models.DateField()
def clean(self):
if self.start_date >= self.end_date:
raise ValidationError("End date cannot be before start date")
If the validations are getting complex and span multiple relations, it is better to write them in the services.
#6: Stop Using Routers
We’ve already discussed not using Viewsets in point #3. Since we have a separate view for each endpoint, we can define URLs separately for each view.
from django.urls import path, include
from project.education.apis import (
CourseCreateApi,
CourseUpdateApi,
CourseListApi,
CourseDetailApi,
CourseSpecificActionApi,
)
course_patterns = [
path('', CourseListApi.as_view(), name='list'),
path('<int:course_id>/', CourseDetailApi.as_view(), name='detail'),
path('create/', CourseCreateApi.as_view(), name='create'),
path('<int:course_id>/update/', CourseUpdateApi.as_view(), name='update')
]
urlpatterns = [
path('courses/', include((course_patterns, 'courses'))),
]
Conclusion
This is so far the best approach to writing scalable Django Rest APIs which makes it easier to hunt for bugs and test the application.
Separating the business logic into selectors and services lets you find errors and fix them easily.
Testing the API would mean, testing the selector and service functions.
For more understanding, watch this video of Radoslav Georgiev where he explains this approach in detail, and refer to this GitHub readme file.