Bradley Kirton's Blog

Published on Jan. 14, 2024

Go home

Thoughts on Django application architecture approaches

Some pre-reading

Before diving into this I would like to provide some prior reading on the subject of application architecture within Django and Python applications which I have used to formulate my current approach.

The list above is mostly about building applications with a layered architecture with ideas rooted in domain driven design. However there are some resources above which question the usefulness of this approach within Django applications. Notable James Bennett's article Against service layers in Django and some other good wisdom from this thread on the Django forum.

The case against service layers

James Bennett's article

Writing a “service” to hide your real data-access layer is basically just Data Mapper with extra steps and without being able to take advantage of off-the-shelf implementations.

I believe this is true and the net effect of trying to make Django fit into a DDD shaped hole is going to create a much larger maintenance burden and result in you re-implementing some of Django's batteries. For example consider a scenario where you have a model layer, an entities layer, a repository layer and then some kind of aggregate or use case. If you decide to change a field name on your model (yes I know this violates the open-for-extension-closed-for-modification design principle but during the early stages of development this is likely unless you have the perfect design up front) you will need to update your model, update the relevant entity definition, update the translation layer between the model and the entity and then update the repository and probably the use case as well. This to me feels like "not fun".

And, ironically, it’s in the kinds of organisations most likely to insist on “enterprise” architecture patterns that the payoff is least likely ever to be realised, because such organisations are almost fanatically averse to the kind of change this architecture is meant to enable.

Having worked for large enterprises I couldn't agree more with this statement.

“things that involve one model instance go on the model class; things involving multiple or all instances go on the model’s QuerySet or manager”.

I like this idea.

Tom Christies's article

A design issue we've found when building large Django applications is that model instances lack any real encapsulation.

Tom is talking about a mechanism to encapsulate or centralise the orchestration of some business process. In his example these business processes are split across the model, view and management commands. The consequence of this is that it becomes more likely that you will put your data model into an inconsistent state when expanding features or applying data fixes.

Never write to a model field or call save() directly. Always use model methods and manager methods for state changing operations.

This means that you should encapsulate processes within a model method or manager method. This is similar to what James Bennett describes. Tom provides some great examples in his blog post.

The case for service layers

21 buttons clean architecture article

Also, self.viewfactory.create() creates the view with all its dependencies_

21 Buttons uses a feature of the builtin class based view View to inject their dependencies into the view.

class ProductViewFactory(object):
    @staticmethod
    def create():
        get_product_interactor = GetProductInteractorFactory.get()
        return ProductView(get_product_interactor)

ViewWrapper.as_view(view_factory=ProductViewFactory)

The GetProductInteractorFactory is the use case or service layer within their application architecture. This gives them encapsulation and dependency injection.

Klaviyo Tech's article

The repository pattern is all about the concept of dependency inversion. ORMs, like the Django ORM or SQLAlchemy, add coupling between domain models and their representation in persistent storage (the database schema).

Klaviyo Tech shows how dependency inversion can be used to decoupled the domain model from the persistence model. This is dependency inversion at more of an architectural level which would allow for swapping out your persistence layer.

The first and most obvious reason to use the repository pattern is for domain separation purposes. Not giving application code access to the underlying ORM (Django in our case) models helps to establish service boundaries.

Similar to the point above, a DDD approach creates a clear separation between the low level implementation details and high level implementation details.

The repository pattern makes testing the application layer easier because it allows leveraging dependency injection to create fake database interactions within tests.

I completely agree with this point specifically with regards to IO bound dependencies like third part API integrations.

The repository layer provides a contract for reading and writing data, and the application layer should only interact with the repository layer.

When your high level implementation details depend on abstractions, refactoring the low level implementation details should not break your application.

My thoughts

Encapsulation

Both the case for and against would agree that process encapsulation is important. The service layer approach encapsulates business logic into the service and repository objects while the case against encapsulates business logic into the models and managers.

Encapsulation: Good idea ✅

Dependency inversion

Dependency inversion is baked into the service layer architecture. Not only is it used to create boundaries between low level implementation details like the Django ORM and your domain model but it also makes use of dependency injection making it easy to switch out repositories during testing with fake ones.

The case against service layers I would argue is a case against using dependency inversion to create a boundary between the high and low level implementation details of your Django application. I believe that this is a pragmatic stance in that you probably won't ever need to switch out the Django ORM for something else. What was not discussed in the case against service layers was the usage of dependency injection for decoupling things we don't have control over like third party APIs. This is however simple to achieve as we can inject our IO bound dependencies into the model or manager methods using plain old function inputs. Given this I would argue that decoupling, not the Django ORM but other functionality, is achievable in the case against as well.

Dependency injection: Good idea ✅

Effort

Fighting the framework is never a good idea. I believe if you want to go the DDD route invariably you will end up re-implementing a lot of the features Django has baked in and integrating those with Django may not work as smoothly as hoped. I would argue the increased complexity of this is not worth it for most projects.

Summary

In summary I feel like any solution that provides encapsulation and allows for the provision of dependencies over which I do not have control is a good idea. I am however more attracted to an overall simpler architecture.

Given this I believe that, baring the separation of concerns between the low and high level implementation details, using a service layer and using a model manager are more similar than they are not; both provide encapsulation and both can take advantage of dependency injection. A service layer however would allow the injection of the model managers which would allow for providing fake managers during testing and therefore better test case isolation.

A user creation example using a service layer

@t.final
@dataclasses.dataclass(frozen=True, slots=True)
class CreateUser:
    """
    Create a user.
    """

    _user_manager: Manager[User]
    _crm_client: CRMClient

    @transaction.atomic
    def __call__(self, data: dict[str, str]) -> User:
        form = CreateUserForm(data=data)

        if not form.is_valid():
            raise InvalidUserDataError(form=form)

        first_name = form.cleaned_data["first_name"]
        last_name = form.cleaned_data["last_name"]

        try:
            user = self._user_manager.objects.create(first_name=first_name, last_name=last_name)
        except Exception as ex:
            raise UserCreationFailed("Failed to create user record.") from ex

        try:
            self._crm_client.create(user_id=user.pk, first_name=first_name, last_name=last_name)
        except Exception as ex:
            raise CRMUnavailableError("CRM is currently unavailable.") from ex

        return user

A user creation example using a a model manager

class UserManager(models.Manager):
    """
    Create a user.
    """

    @transaction.atomic
    def create_user(self, data: dict[str, str], crm_client: CRMClient) -> User:
        form = CreateUserForm(data=data)

        if not form.is_valid():
            raise InvalidUserDataError(form=form)

        first_name = form.cleaned_data["first_name"]
        last_name = form.cleaned_data["last_name"]

        try:
            user = User.objects.create(first_name=first_name, last_name=last_name)
        except Exception as ex:
            raise UserCreationFailed("Failed to create user record.") from ex

        try:
            crm_client.create(user_id=user.pk, first_name=first_name, last_name=last_name)
        except Exception as ex:
            raise CRMUnavailableError("CRM is currently unavailable.") from ex

        return user