Roman Imankulov

Roman Imankulov

Full-stack Python web developer from Porto

search results (esc to close)
15 Aug 2023

Django Admin and Service Layer

Services are a missing layer in the Django architecture. Django does not provide guidance on where to place complex logic for modifying multiple models, but many development teams have chosen to follow the clean architecture pattern and introduce a service layer. This layer acts as a middleman between interfaces such as views or API endpoints, and models.

However, in Django, many features are built around direct use of models. For example, Django admin assumes that models are directly modified and calls the save() method on changes.

Because of this, the common approach to customizing model behavior is to override the save() method. For example, when saving a payment object, you may update the user’s balance, send a notification email, and perform other tasks.

This is not necessary. Django admin works well with the concept of a service layer. Most of the time, all you need to do is override the save_model() method to call a service function. However, there are some subtle details that we need to be aware of.

Let’s consider an example to see the advantages and drawbacks of introducing the service layer and making it work with Django admin.

Suppose we have a model called BlogPost. When the slug of a blog post is updated, we want to save the previous slug value in a separate model called BlogPostSlugHistory to redirect visitors from the old blog post URL to the new one.

Before: Create BlogPostSlugHistory in the save() function. Use a basic admin setup.

# file: models.py

class BlogPostSlugHistory(models.Model):
    blog_post = models.ForeignKey("blog.BlogPost", on_delete=models.CASCADE)
    slug = models.SlugField(max_length=150, unique=True)

class BlogPost(models.Model):
    slug = models.SlugField(max_length=150, unique=True)
    ...

    def save(self, **kwargs):
        if self.pk:
            orig = BlogPost.objects.get(pk=self.pk)
            if orig.slug != self.slug:
                BlogPostSlugHistory.objects.create(
                    blog_post=orig, slug=orig.slug,
                )
        super().save(**kwargs)

# file: admin.py

@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
    pass

Advantages:

  • There are fewer moving parts: just a model and a bare bones admin.
  • Django guarantees that the slug change is always processed. A BlogPostSlugHistory object is created for every change of the slug, regardless of how you change it: from the admin, from an API endpoint, or from an ad-hoc console command.

Disadvantages:

  • save() is getting messy quite quickly. Every piece of business logic or custom validation goes into save(), making it hard to reason.
  • There is no natural way to skip or customize the business logic in save().

After:

Let’s extract the updating of the slug into a service function.

# file: models.py

class BlogPostSlugHistory(models.Model):
    blog_post = models.ForeignKey("blog.BlogPost", on_delete=models.CASCADE)
    slug = models.SlugField(max_length=150, unique=True)

class BlogPost(models.Model):
    slug = models.SlugField(max_length=150, unique=True)
    ...

# file: services.py

def update_slug(blog_post: BlogPost, slug: str) -> BlogPost:
    if slug != blog_post.slug:
        BlogPostSlugHistory.objects.create(blog_post=orig, slug=orig.slug)
    blog_post.slug = slug
    blog_post.save()
    return blog_post

# file: admin.py
from blog.services import update_slug


@admin.register(BlogPost)
class BlogPostSlugAdmin(admin.ModelAdmin):
    fields = ("slug",)

    def save_model(self, request, obj, form, change):
        update_slug(obj, form.cleaned_data["slug"])

Now, whenever you need to update the model’s slug, you call update_slug() instead of manually setting the slug attribute and calling save.

Comparing to the previous method, having a service method is semantically cleaner. Whenever you see update_slug somewhere in the code, you immediately know its intent.

However, there are also clear downsides to using services.

The service function update_slug() only does one thing. If you want to update the slug and the content in one action, you need to call two service functions. I don’t consider this a disadvantage because the advantages of semantic clarity and the ability to separate independent pieces of code outweigh the inconveniences.

Additionally, there are no guardrails that would prevent you from manually setting the slug attribute and saving the object. There is no warning that a BlogPostSlugHistory is not created. This can eventually result in data inconsistencies and difficult-to-spot errors.

To overcome this approach, all developers need to have a clear understanding of the service layer’s intent and exercise discipline. For example, you can establish a rule that models should only be modified directly from service functions and use linting or code reviews to enforce it. However, this may not be easy to implement given how deeply ingrained the “change attributes, save model” pattern is in Django itself. Depending on your team’s situation or the quality and size of your codebase, this could become a show-stopper. This is where you find yourself fighting against the framework.

A yet another place to fight against the framework: the admin app. Provided that you have multiple service functions to change different aspects of the object, it feels natural to create multiple admin entry points for this.

For example, something like this would make sense for modifying slugs and content separately.

# file: admin.py

@admin.register(BlogPost)
class BlogPostSlugAdmin(admin.ModelAdmin):
    fields = ("slug",)

    def save_model(self, request, obj, form, change):
        update_slug(obj, form.cleaned_data["slug"])

@admin.register(BlogPost)
class BlogPostBodyAdmin(admin.ModelAdmin):
    fields = ("body",)

    def save_model(self, request, obj, form, change):
        update_body(obj, form.cleaned_data["body"])

However, Django does not allow you to register two. If you try this and run your service, you will get an error message.

django.contrib.admin.sites.AlreadyRegistered: The model BlogPostSlugHistory is already registered with ‘BlogPostSlugAdmin’. 

Of course, you can use a single admin command, like this:

# file: admin.py

@admin.register(BlogPost)
class BlogPostSlugAdmin(admin.ModelAdmin):
    fields = ("slug", "body")

    def save_model(self, request, obj, form, change):
        update_slug(obj, form.cleaned_data["slug"])
        update_body(obj, form.cleaned_data["body"])

Another workaround is to use a proxy model, as suggested in this StackOverflow discussion.

# file: admin.py

# Define a proxy model for update_slug()
class BlogPostForUpdateSlug(BlogPost):
    class Meta:
        proxy = True

# Define an admin command for the command defined above
@admin.register(BlogPostForUpdateSlug)
class BlogPostSlugAdmin(admin.ModelAdmin):
    fields = ("slug",)

    def save_model(self, request, obj, form, change):
        update_slug(obj, form.cleaned_data["slug"])

# Do the same for update_body()
class BlogPostForUpdateBody(BlogPost):
    class Meta:
        proxy = True

@admin.register(BlogPostForUpdateBody)
class BlogPostBodyAdmin(admin.ModelAdmin):
    fields = ("body",)

    def save_model(self, request, obj, form, change):
        update_body(obj, form.cleaned_data["body"])

The StackOverflow answer provides a code sample where a function is used to dynamically create a proxy model and register the model admin, thus reducing the amount of boilerplate code.

Using a service layer in Django can be a bit tricky, but with some tweaks and understanding, you can make it work and keep your code cleaner.

Roman Imankulov

Hey, I am Roman, and you can hire me.

I am a full-stack Python web developer who loves helping startups and small teams turn their ideas into products.

More about me and my skills