Post

How to implement a service layer in Django + Rest Framework

From this article you will learn:

  • what is the service layer?
  • the problem solved by a service layer
  • how to refactor to services from ModelSerializers

What is the service layer?

A service layer is a set of classes or functions, called services, that together form an API for a single package or application. We can distinguish two kinds of services - application services and domain services. In this article, I will be focusing solely on the first type, i.e. application services.

A single application service provides an indivisible piece of functionality for an actor using the system, e.g. shopper, traveller, merchant. If we take an example of an e-commerce application, we could have the following application services:

  • add an item to a basket
  • increase/decrease items count in a basket
  • confirm order
  • pay for the order

Each application service stands for handling a single action of a user. If, for example, some business process requires multiple steps (like ordering - from confirming to entering address details to paying), we do not model them as one application service. Each user action gets its own service.

Service layer is nothing more but a set of application services. Code-wise, service layer is implemented as classes, methods (like Facade pattern or functions:

# services as classes
class ConfirmOrder:
    def confirm(...):
        ...

class PayForTheOrder:
    def pay(...):
        ...

# services as functions
def confirm_order(...):
    ...

def pay_for_the_order(...):
    ...

# services as methods (Facade)
class OrdersApplicationFacade:
    def confirm_order(self, ...):
        ...

    def pay_for_the_order(self, ...):
        ...

Why use services?

One place to look for application logic

Services aim to provide a single place to look for application logic.

The service layer is ignorant of an actor interface or a delivery mechanism. They do not know whether they are called from REST[ful] API, Celery task or CLI command. Hence, application services introduce a pleasant feeling of uniformity throughout the project. If one wants to know what the application does, they can just read services.

Easier testing

That lack of knowledge about the outer world let us test application logic easier because we require less information to exercise it. In particular, Acceptance/Functional/BDD tests can use a service layer to extensively test our application. Even without more tricks, such tests will be slightly faster because they will not run the framework code.

Put logic that doesn't fit elsewhere

The service layer is also a great place to put a logic when it does not fit other places - especially models. Don't get me wrong, it's perfectly fine to implement a method like confirm in your Order model to check if it has not been confirmed before, but sending an e-mail from model's guts or calling 3rd party service's API should at least make you wonder for a moment if there is a better way. If our models implement the majority of logic, we call them Fat Models. It's a good approach if a model can fulfil the request using its own data or related models. Does not work well if we need to reach outside and makes model classes unmaintainable.

Putting business logic that doesn't fit into models in views or serializers sounds like a good idea, but also mixes two worlds - web and the service that is provided by the application. As a result, view or serializer classes might contain code that is completely unrelated to handling web requests.

See also Where to put business logic in Django?

Bridge the gap between business and software development

The most important argument in favour of service layers is that thinking in terms of application services helps to bridge the gap between business and software development. Traditionally, developers would receive requirements and translate them in their heads to models (or simply database tables), endpoints etc. Although it works just fine for simpler applications, it causes a lot of knowledge to get lost in the longer term. Understanding of what the goal is and business processes exist temporarily in developers minds. Perhaps also in as some sort of documentation artefact that is detached from code anyway. Assuming the project is a success and development proceeds, the code becomes more and more complex.

Not a silver bullet

There is no silver bullet, though. If your application is a simple database browser or as complex as a blog from Django tutorial, don't bother about services ;)

How to implement a service layer in Django?

Let's assume we have an application that uses Django and Rest Framework in tandem. Usually, our application consists of a bunch of ViewSets with ModelSerializers. The example we will be analysing is about making a hotel booking. That involves:

  • calling 3rd party to do the reservation
  • saving HotelBooking model to the database

A "standard" way of implementing this scenario is to abuse a ViewSetr or ModelSerializer for HotelBooking and squeeze calling 3rd party API somewhere before saving the model.

# at least viewset is dead simple
class HotelBookingViewSet(CreateModelMixin, GenericViewSet):
    queryset = HotelBooking.objects.all()
    serializer_class = HotelBookingSerializer

class HotelBookingSerializer(serializers.HyperlinkedModelSerializer):
    start_date = serializers.DateField()
    end_date = serializers.DateField()
    special_wishes = serializers.CharField()
    hotel = serializers.HyperlinkedRelatedField(queryset=Hotel.objects.all(), view_name='hotel-detail')

    class Meta:
        model = HotelBooking
        fields = (
            "start_date",
            "end_date",
            "special_wishes",
            "hotel",
        )

    # skip validation code for clarity. It should be there

    def create(self, validated_data):
        payload = self._prepare_supplier_payload(validated_data)

        try:
            response = requests.post("https://some-3rd-party/bookings/hotel", json=payload)
            response.raise_for_status()
        # if something goes wrong - fail the booking 
        except RequestException:
            logger.log("(Log what went wrong)")
            raise ServiceUnavailable

        # take data from 3rd party response and put it to model
        response_json = response.json()
        validated_data["supplier_booking_reference"] = response_json["refNumber"]

        # finally, save model
        instance = super().create(validated_data)

        return instance

    # build arguments for third party
    @staticmethod
    def _prepare_supplier_payload(validated_data):
        start_date = validated_data["start_date"].strftime("%d-%m-%Y")
        number_of_days = (validated_data["end_date"] - validated_data["start_date"]).days
        return {
            "hotelId": validated_data["hotel"].supplier_id,
            "startDate": start_date,
            "guestWish": validated_data["special_wishes"],
            "days": number_of_days
        }

As you can see, the majority of code has nothing to do actual (de)serialization. The logic for calling 3rd party is literally squeezed between validation and model saving. Now, you can confidently tell that when a lot happens between these two, this approach starts to collapse.

Refactoring to use Service

Radical way

There are several possible approaches to do that. One, very radical, comes down to:

  • get rid of ModelSerializer at once and use "normal" Serializer instead
  • Move application-specific code to a service
  • Create a @dataclass for service arguments
  • use more basic ViewSet from Rest Framework and orchestrate serializer - service data flow

First, let's see how view and serializer change:

# Now ViewSet is a bit more complex. Due to lack of proper 
# base class in rest_framework, we need to partially do the work 
# of CreateModelMixin - call serializer validation
class HotelBookingViewSet(ViewSet):
    def create(self, request):
        # we invoke validation and build service input argument
        # in a form of a DTO (DataTransferObject)
        dto = self._build_dto_from_validated_data(request)

        hotel_booking_service = HotelBookingService()
        try:
            hotel_booking_service.book(dto)
        except HotelBookingFailure:
            return JsonResponse({"success": False}, status=503)

        return JsonResponse({"success": True})

    def _build_dto_from_validated_data(self, request) -> dict:
        serializer = HotelBookingSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        data = serializer.validated_data
        return HotelBookingDto(
            start_date=data["start_date"],
            end_date=data["end_date"],
            hotel_id=data["hotel"].id,
            hotel_supplier_id=data["hotel"].supplier_id,
            special_wishes=data["special_wishes"],
        )

# HotelBookingSerializer no longer inherits from ModelSerializer 
class HotelBookingSerializer(serializers.Serializer):
    start_date = serializers.DateField()
    end_date = serializers.DateField()
    special_wishes = serializers.CharField()
    hotel = serializers.HyperlinkedRelatedField(queryset=Hotel.objects.all(), view_name='hotel-detail')

There goes service code:

# DataTransferObject for input data
@dataclass
class HotelBookingDto:
    start_date: date
    end_date: date
    hotel_id: int
    hotel_supplier_id: str
    special_wishes: str


class HotelBookingService:
    def book(self, dto):
        # logic itself is not different - it was simply moved
        # from former HotelBookingSerializer
        payload = self._prepare_supplier_payload(dto)

        try:
            response = requests.post("https://some-3rd-party/bookings/hotel", json=payload)
            response.raise_for_status()
        except RequestException:
            raise HotelBookingFailure

        HotelBooking.objects.create(
            hotel_id=dto.hotel_id,
            start_date=dto.start_date,
            end_date=dto.end_date,
            special_wishes=dto.special_wishes,
        )

    # build arguments for third party
    @staticmethod
    def _prepare_supplier_payload(dto):
        start_date = dto.start_date.strftime("%d-%m-%Y")
        number_of_days = (dto.end_date - dto.start_date).days
        return {
            "hotelId": dto.hotel_supplier_id,
            "startDate": start_date,
            "guestWish": dto.special_wishes,
            "days": number_of_days
        }

And that's it. Certainly, this example could could use a little refactoring, especially for a view. Due to lack o proper abstraction in rest framework, I had to write some glue code between viewset and serializer. If I had more of them, I would definitely create some base class like ServiceView that instantiates appropriate serializer and creates DTO automatically. Speaking of DTO...

Well, not exactly the same (there is small difference for hotel versus hotel_id and hotel_supplier_id), but certainly something's up. From my perspective, that's just a consequence of Rest Framework serializers' design. We would not have that issue with Pydantic that is able to both validate data and provide a class for it. Another possible solution is to just pass validated_data to service, but at least write a TypedDict to formalize the contract. Oh, and the most important thing - service must not know if it's called from API, so it may not receive HttpRequest instance.

Less radical way with ModelSerializer still in place

One nice thing about ModelSerializer is that they handle saving models for you, including many-to-many relations. Also, it is easy to build an endpoint that returns model data after the operation is completed. If saving the model is the last or first step in the process, you may still easily adopt services by extending CreateModelMixin (viewset mixin) perform_create method. First, let's see the code of original mixin:

class CreateModelMixin:
    """
    Create a model instance.
    """
    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer)
        headers = self.get_success_headers(serializer.data)
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

    def perform_create(self, serializer):
        # That's where we can stick our logic!
        serializer.save()

    def get_success_headers(self, data):
        ... # irrelevant for the example, so skipping

Using the same example with booking, we could use perform_create as follows:

class HotelBookingViewSet(CreateModelMixin, GenericViewSet):
    def perform_create(self, serializer):
        dto = self._get_dto_from_validated_data(serializer.validated_data)
        hotel_booking_service = HotelBookingService()
        try:
            hotel_booking_service.book(dto)
        except HotelBookingFailure:
            raise ServiceUnavailable

        super().perform_create(serializer)  # save in the end

    @staticmethod
    def _get_dto_from_validated_data(self, validated_data):
        # the same as in previous code example
        data = validated_data
        return HotelBookingDto(
            start_date=data["start_date"],
            end_date=data["end_date"],
            hotel_id=data["hotel"].id,
            hotel_supplier_id=data["hotel"].supplier_id,
            special_wishes=data["special_wishes"],
        )

Although this approach reuses more code from the framework we must ask ourselves if we do not give up too easy. The goal is to separate business logic from the framework after all.

Summary

Services are handy (and relatively cheap!) way to decouple application logic from the framework. It's not only about code organization and improved testability. The most important aspect is bridging the gap between business and software development. Hence, the focus is on using patterns that expose our business logic in plain sight, not hide it in serializers/views guts.

Bear in mind we barely scratched the surface of possible solutions. If you are interested in more advanced techniques, read my post about The Clean Architecture in Python.

Further reading on service layers in Django:

This post is licensed under CC BY 4.0 by the author.

Comments powered by Disqus.