Django Best Practices — Refactoring Django Fat Models

6 min read
Django Best Practices — Refactoring Django Fat Models

Every developer understands the advantages of having clean, easy to read and understand coding. Which is where tools like QuerySet and Manager come into play when using Django. They embed basic DRY and KISS principles that most forget about when using ORM in many frameworks. This has lead to the use of Fat Models which might seem fine in the first place but can lead to complications down the road.

Why refactoring Django fat models?

There have been several articles written in the past that supported the use of Fat Models. This we believe to be a fundamental error. The notion where all or most of your business logic and rules are handed over to Django Models only lead to complications down the road. But some if not most developers have been doing this for years and would continue to do so but here are our thoughts on why this shouldn't be the case.

Incapsulation

Fat Django models create unnecessary coupling

First on our list would be the interaction between various models and functions. What we mean by this is, most business logic flows. This means there is a high chance of making calls to various methods that are found in other models and functions from your source flow. This might not seem like much of a big deal at first but over time it starts to create unnecessary dependencies. This is further complicated with models that are core parts of your application.

Down the line, there is a higher probability of you needing core code modifications simply because of these dependencies. This could be as a result of changes made to other models such as a method signature or a side effect that was added into it later on.

Unnecessary Coupling

This just creates more workload down the line as the more fat models are used the more exaggerated the problem becomes. Until a point is reached where it becomes impossible to track all the changes.

Fat Django models are hard to test

There is a reason why developers are always trying to make their coding as simple as possible. One of which happens to be debugging during the testing phase. The bigger the flow the larger the testing and mocking required.

You do not wish to be trapped in a web if near-endless dependencies. Keep your methods as simple as possible and testing should be a lot easier. It would be strange to find a developer without their own horror story about complex methods that gave them sleepless nights while testing.

Separation of business logic and data access in Django models

Now knowing that Fat Models are not the way to go. The next step is to understand how we can refactor these models into clean simple coding. This is done with the help of both the Model Manager and QuerySets.

Take advantage of model managers

We start with a little organization:

  1. First, creating new Users can go in a UserManager(models.Manager).

  2. gory details for instances should go on the models.Model

  3. gory details for queryset could go in models.Manager

It might be wise to create each User one at a time, so you may think that it should live on the model itself, but when creating the object, you probably don't have all the details:

For Example:

class UserManager(models.Manager):

  def create_user(self, username, ...):
  # plain create

  def create_superuser(self, username, ...):
  # may set is_superuser field.

  def activate(self, username):
  # may use save() and send_mail()

  def activate_in_bulk(self, queryset):
  # may use queryset.update() instead of save()
  # may use send_mass_mail() instead of send_mail()

Encapsulating database queries with Django querysets

Next, we can have Django QuerySets be responsible for encapsulating all our database queries. Some of the benefits of doing this are:

  • Reusability: This makes the method reusable in other locations. Plus once a method is reusable there is no need to recreate again.

  • Unification: If the query has value then there is no need to recreate in multiple locations when it can be unified in a single space.

  • Readability: We cut down the excess complexity associated with the methods and create a simple, readable and well-defined query.

  • Orthogonality: Changes become easier to make. As long as the encapsulation preserves the original interface, you should be able to optimize, modify or add business logic stipulations while maintaining the calling code.

Let's take a look at a practical example. Here we are working with a bit a Category and Product class.

Starting with our model file:

class Category(models.Model):
  name = models.CharField(max_length=100)

  def __str__(self):
    return self.name


class Product(models.Model):
  name = models.CharField(max_length=120)
  price = models.FloatField(blank=True, null=True)
  stock = models.IntegerField(default=1)
  category = models.ForeignKey(Category, on_delete=models.CASCADE)

  def __str__(self):
    return self.name

Once created we should note that each product is related to one category. Say that we have four products with categories:

  1. Sport Car [category: car]
  2. Truck [category: car]
  3. Coupe [category: car]
  4. Bicycle [category: other]

If we want to get all products with category “car” our query will look like this:

Cars = Product.objects.filter(category__name__name=’car’)

For now, everything is simple and clear. But what if we want to show cars on many pages on-site? We copy this multiple times.

Now with help comes our Manager and QuerySets

We create file managers.py

from Django.DB import models

class ProductQuerySet(models.query.QuerySet):
  def get_by_category(self, category_name):
    return self.filter(category__name=category_name)

  def get_by_price(self):
    return self.order_by('price')

class ProductManager(models.Manager):

  def get_queryset(self):
    return ProductQuerySet(self.model, using=self._db)

  def get_by_category(self, category_name):
    return self.get_queryset().get_by_category(category_name)

  def get_by_price(self):
    return self.get_queryset().get_by_price()

Don’t forget to import our manager in the model and assign it to a variable of product or manager.

from .managers import ProductManager

class Product(models.Model):
...

objects = ProductManager()

Now query will look like this:

Car = Product.objects.get_by_category("cars").get_by_price()

PRO TIP: Django's ORM empowers developers by offering a seamless bridge to databases without the intricacies of detailed SQL queries. Yet, the convenience of the ORM doesn't absolve us from the pitfalls of suboptimal queries which can throttle performance and burden servers.

Read 9 Quick Ways to Optimize & Speed Up Queries in Django and discover strategies to refine your Django query performance, marrying the best of both worlds: the ease of ORM and the speed of optimized database interactions,

Summary

The temptation to just throw everything into one large Fat Model is very compelling and most of us have been falling into this trap for a long time. Yet Django has tools that make this a thing of the past. We just have to make use of them. Tools like Manager and QuerySet can become the vital arsenal needed to create simple clean coding.

With them, we don’t need to repeat our filter in every view or worst change it multiple times if the name will change. But we should think if we want to repeat code once or twice in the future or add a level of complication.

If you’re looking for a Python development company, we’d love to help. Python development experts at SoftKraft will help you plan, design and build a web solution without the headache. Reach out to get a free quote!