Journeying through SOLID Principles: API Development with Django Rest Framework

Rania Maharani
8 min readFeb 24, 2024

--

image from https://www.django-rest-framework.org/

Developing APIs with Django Rest Framework as a toolkit gave us a couple advantages. A few reasons why you should considered on using a framework are the serialization that allow complex datatypes to be easily rendered into content types such as JSON, reusable code with class-based views, wide developer community resources for better development practices, built-in testing tools, and various other compelling reasons.

As you see in the title of this article, we’re going on a journey through SOLID principles, but before that, what exactly is SOLID?

image from www.thoughtworks.com/

SOLID stands for five design principles:

  • Single Responsibility Principle (SRP)
  • Open-Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

As a structured design approach, SOLID can assist in creating code that is modular, easy to maintain, and capable of scaling, thus improving its extensibility and testability.

Following that, let’s take a glimpse at how I infused the essence of SOLID into my recent project.

S — Single Responsibility Principle

This principle suggests that every class and function should have one responsibility or single job to do. There are a few benefits to following this principle: fewer test cases, lower coupling, easier to maintain, and being well organized, which makes it easier to find.

@permission_classes([IsAuthenticated])
class QuestionGet(ViewSet):
"""
ViewSet to return all or specific questions.
"""

pagination_class = CustomPageNumberPagination()
service_class = QuestionService()

@extend_schema(
description='Request and Response data to get a question',
responses=QuestionResponse,
)
def get(self, request, pk):
question = self.service_class.get(user=request.user, pk=pk)
serializer = QuestionResponse(question)

return Response(serializer.data)

@extend_schema(
description='Returns recently added question',
responses=QuestionResponse,
)
def get_recent(self, request):
# implementation
def get_all(self, request):
# implementation
def get_privileged(self, request):
# implementation
def get_matched(self, request):
# implementation

@permission_classes([IsAuthenticated])
class QuestionPut(APIView):
@extend_schema(
description='Request and Response data for updating a question',
request=BaseQuestion,
responses=QuestionResponse,
)
def put(self, request, pk):
request_serializer = BaseQuestion(data=request.data)
request_serializer.is_valid(raise_exception=True)
question = QuestionService.update_mode(self, user=request.user, pk=pk, **request_serializer.validated_data)
response_serializer = QuestionResponse(question)

return Response(response_serializer.data)

@permission_classes([IsAuthenticated])
class QuestionDelete(APIView):
@extend_schema(
description='Request and Response data for deleting a question',
)
def delete(self, request, pk):
question = QuestionService.delete(self, user=request.user, pk=pk)
response_serializer = QuestionResponse(question)

response_data = {
'message': 'Analisis berhasil dihapus',
'deleted_question': response_serializer.data
}

return Response(response_data, status=status.HTTP_200_OK)

As shown above, each class which has functions inside it has a specific task, and you can easily understand their purpose based on the function name, this is in line with the Single Responsibility Principle. For example, the `QuestionGet` class has a single responsibility: retrieving questions from the database. While it may contain multiple functions, they all share the common goal of fetching questions in different ways, aligning with the principle of single responsibility.

Additionally, I organized my project into view, service, and model layers. Views manage incoming requests and responses, services handle business logic, and models encapsulate data operations. This structure also adheres to the single responsibility principle. For Django Rest Framework (DRF), dividing the files in this way makes development and debugging much easier when there are bugs or errors.

O — Open-Closed Principle (OCP)

This principle implies that classes should be open to extension while remaining closed to modification. This approach helps prevent the introduction of bugs or errors when altering existing classes, ensuring that adding new features to the system does not necessitate changes to the existing codebase.

class BaseQuestion(serializers.Serializer):
MODE_CHOICES = Question.MODE_CHOICES

class Meta:
ref_name = 'base question'

mode = serializers.ChoiceField(choices=MODE_CHOICES)
class QuestionRequest(BaseQuestion):
class Meta:
ref_name = 'question request'

question = serializers.CharField()

class QuestionResponse(BaseQuestion):
class Meta:
ref_name = 'question response'

id = serializers.UUIDField()
question = serializers.CharField()
created_at = serializers.DateTimeField()

In the provided example, BaseQuestion has modeas its attribute, and the other classes extends BaseQuestion to add new attributes. However, they still retain the modeas the attribute, demonstrating adherence to the open-close principle.

L — Liskov Substitution Principle (LSP)

Liskov Substitution states that an extended class can serve as a substitute for the base class without modifying the program’s behavior. If we don’t follow the principle, there are challenges, such as less readable and misleading code.

class BaseQuestion(serializers.Serializer):
MODE_CHOICES = Question.MODE_CHOICES

class Meta:
ref_name = 'base question'

mode = serializers.ChoiceField(choices=MODE_CHOICES)

class QuestionRequest(BaseQuestion):
class Meta:
ref_name = 'QuestionRequest'

title = serializers.CharField(max_length=40)
question = serializers.CharField()
tags = serializers.ListField(
min_length=1,
max_length=3,
child=serializers.CharField(max_length=10))

In the example of serializer classes above, you’ll notice that QuestionResponse extends from BaseQuestion. Because both of them share a common attribute, if we use different serializers in a view, they can handle same input and output data. For example base question is used in patch question mode:

@extend_schema(
description='Request and Response data for updating question mode',
request=BaseQuestion,
responses=QuestionResponse,
)
def patch_mode(self, request, pk):
request_serializer = BaseQuestion(data=request.data)
request_serializer.is_valid(raise_exception=True)
question = self.service_class.update_question(user=request.user, pk=pk, mode=request_serializer.validated_data.get('mode'))
response_serializer = QuestionResponse(question)

return Response(response_serializer.data)

In the example above, using the base question resulted in a successful HTTP 200 OK response, with the body containing only one attribute.

Using Base Question

But if we change the serializer into QuestionRequest:

def patch_mode(self, request, pk):
request_serializer = QuestionRequest(data=request.data)

It will still function properly, requiring only a change in the function in `views.py` without modifying `service.py`. As shown below, the status remains 200, and only the mode (the attribute that we want to change) will need to be checked and updated.

Using QuestionRequest

I — Interface Segregation Principle (ISP)

To adhere ISP, classes should only have the methods which they need. That way, classes only need to be focus solely on the methods that it needs. Implementing this a is approach promotes loose coupling, separation of concerns, and more easier to search and maintain the codebase.

@permission_classes([IsAuthenticated])
class QuestionDelete(APIView):
@extend_schema(
description='Request and Response data for deleting a question',
)
def delete(self, request, pk):
service_class = QuestionService()
question = service_class.delete(user=request.user, pk=pk)
response_serializer = QuestionResponse(question)

response_data = {
'message': 'Analisis berhasil dihapus',
'deleted_question': response_serializer.data
}

return Response(response_data, status=status.HTTP_200_OK)

The QuestionDelete class only implements the `delete` function, indicating that it requires only the functionality related to handling HTTP DELETE requests. This illustrates adherence to the Interface Segregation Principle (ISP) by ensuring that the class isn’t compelled to rely on unnecessary methods or interfaces that it doesn’t utilize. Moreover, within the delete function, it exclusively invokes the delete function in services/question.py, without depending on any other functionalities other than what the class was meant to do; delete a question.

Each class in our project (BE), especially at views.py, we divided each class for POST, GET, DELETE, and PUT so that each class can only have the methods that necessary for them. For example, we have a QuestionPost class that is solely responsible for handling POST requests, a QuestionGet class dedicated to handling GET requests, a QuestionDelete class for DELETE requests, and a QuestionPatch class for PATCH requests. By doing this, we ensure that each class has only the methods necessary for its particular operation. This enhances the maintainability and clarity of the code, reducing risks and bugs, because the classes are well-defined.

D — Dependency Inversion Principle (DIP)

The principle emphasizes decoupling of software modules, which states that both high-level modules and low-level modules should rely on abstractions.

The example for this is calling the services function within the views. The abstraction implemented by calling the method from service without having to concern about the implementation. We just have to set the attribute needed like below. In the example, I called the same services function which is update_question. The difference is on the attribute that were sent.

@permission_classes([IsAuthenticated])
class QuestionPatch(ViewSet):
service_class = QuestionService()

@extend_schema(
description='Request and Response data for updating question mode',
request=QuestionRequest,
responses=QuestionResponse,
)
def patch_mode(self, request, pk):
request_serializer = QuestionRequest(data=request.data)
request_serializer.is_valid(raise_exception=True)
question = self.service_class.update_question(user=request.user, pk=pk, mode=request_serializer.validated_data.get('mode'))
response_serializer = QuestionResponse(question)

return Response(response_serializer.data)

@extend_schema(
description='Request and Response data for updating question title',
request=QuestionTitleRequest,
responses=QuestionResponse,
)
def patch_title(self, request, pk):
request_serializer = QuestionTitleRequest(data=request.data)
request_serializer.is_valid(raise_exception=True)
question = self.service_class.update_question(user=request.user, pk=pk, title=request_serializer.validated_data.get('title'))
response_serializer = QuestionResponse(question)

return Response(response_serializer.data)

@extend_schema(
description='Request and Response data for updating question tags',
request=QuestionTagRequest,
responses=QuestionResponse,
)
def patch_tags(self, request, pk):
request_serializer = QuestionTagRequest(data=request.data)
request_serializer.is_valid(raise_exception=True)
question = self.service_class.update_question(user=request.user, pk=pk, tags=request_serializer.validated_data.get('tags'))
response_serializer = QuestionResponse(question)

return Response(response_serializer.data)

By calling service functions from within the views, we maintain a clean separation between the view logic and the business logic. This abstraction ensures that the views remain simple and focused on handling HTTP requests, while the service layer manages the core application logic. As a result, we can easily modify or extend the business logic without affecting the view layer, making the codebase more maintainable and scalable. This approach also adheres to the Dependency Inversion Principle by depending on abstractions rather than concrete implementations.

Another example is shown and descripted below.

class QuestionAPI(APIView):
def post(self, request):
# implementation here

def get(self, request, pk):
# implementation here

def put(self, request, pk):
# implementation here
urls.py

app_name = 'validator'

urlpatterns = [
path('new/', QuestionAPI.as_view(), name="create_question"),
path('<uuid:pk>/', QuestionAPI.as_view(), name="get_question"),
path('update/<uuid:pk>/', QuestionAPI.as_view(), name="put_question")
]

In the provided example, within the urls.py file, we utilize abstractions by referencing QuestionAPI.as_view() instead of explicitly stating the function views. This approach allows Django Rest Framework (DRF) to automatically associate the appropriate method based on the request method sent. This abstraction simplifies the URL configuration process and is one of the advantages of using DRF.

Before adopting this principle, I had to remember function names, which were often not straightforward. In my previous Django projects, I used long function or class names like “update_an_attribute.” If I needed to change a name, I had to remember to update it in both views and URLs.

Conclusion!

In summary, adhering to SOLID principles can aid in developing modular, maintainable, and scalable code, thereby enhancing its extensibility and testability. While there is no strict consensus on how to apply each rule, following these principles can lead to more robust software architecture.

Before learning these principles in my previous college project, I faced challenges in maintaining and expanding the code. Debugging was difficult, and even simple tasks like adding attributes made the code messy and prone to bugs.

References:

--

--