When we start prototyping our first web application with Django, we always tend to create one Django app and put all the models into that app. The reason is simple – there are not that many models and the business logic is simple. But with the growth of the business, more models and business logic get added–one day we might find our application in an awkward position: it’s harder and harder to locate a bug and takes longer and longer to add new features, even if they are simple. In this blog, we’ll talk about how to use different Django apps to reorganize models and business logic so that it scales with the growth of our business. We will also illustrate the flow of the change with a simple case study.
Prototyping stage – a simple case study
We start from a simple application called “Weblog” which allows the users to create and publish blogs. We create an app called weblog. And the models are as follows.
class Author(models.Model): user = models.OneToOneField('authentication.User') ... class Blog(models.Model): author = models.ForeignKey(Author) category = models.CharField( choices=((TECH, 'Tech'), (SPORT, 'Sport'), (FASHION, 'Fashion')), max_length=10 ) content = models.TextField( max_length=10240, blank=True ) is_published = models.BooleanField(default=False) ...
Now assume the rest of the application is completed based on the models above. The users can now login, create and publish their blogs using our application.
Evolving approach I – keep adding new business logic into the same app
Say we have a new requirement. In order to attract more authors to create content using our application, we’ll pay the authors based on view counts. The price is $10 for every 1000 views. And the payout is sent once a month.
Since the new requirement sounds pretty simple, a regular approach that puts the new models and logic into the existing app is good enough. First, we add a new model in the “weblog” app like below:
... class MonthlyViewCount(models.Model): blog = models.ForeignKey(Blog) month = models.DateField() count = models.PositiveIntegerField(default=1) ...
For each new view of a blog, we increase the count based on the month or create a new MonthlyViewCount record if this is the first view in this month. The code looks like:
class Blog(models.Model): ... def increase_view_count(self): now = timezone.now() month = datetime(year=now.year, month=now.month, day=1) view_count, created = \ MonthlyViewCount.objects.get_or_create( blog=self, month=month ) if not created: view_count.count += 1 view_count.save()
At the end of each month, we run a cron task which aggregates all the view counts for each author and sends the payment to them accordingly. Here’s the pseudo code:
@periodic_task(run_every=crontab(day_of_month=1, hour=0, minute=1)) def send_viewcount_payment_to_authors(): for author in Authors.objects.authors_with_viewscounts( month=previous_month): total_view_count = author.get_total_view_count_for_month( month=previous_month ) payment_amount = (total_view_count / 1000) * 10.00 author.send_payment(payment_amount)
The approach above seems fine for handling a simple change request like this. But there will always be new requirements coming in.
Evolving approach II – organize the business logic into different Django apps
Now we have a new requirement. To encourage the authors to create content in certain categories, the business team wants to adjust the award strategy into a category-based one. Each category will have a different award price. Say the award price table looks like the following:
|Price (per 1000 views)|
This new requirement also looks simple enough that we can just create a new model in the existing app to store the category base price and update the cron task to look for the category-based price when aggregating the total. The whole change will take less than 30 minutes and everything is good to go.
But there are two major problems with cranking more and more new models and business logic into the main ‘weblog’ app.
- The main app becomes responsible for the business logic of different domain knowledge. The class files become bigger and unmaintainable.
- The agility is compromised since it is harder to debug an issue and adding new features is slower.
In Django, we can use different apps to organize the business logic of different domains and Signals to handle the communication between apps. In our example, we’ll try to move all the billing-related models and methods into the new app called ‘billing’.
First, we move all the billing-related models into the new billing app.
... class MonthlyViewCount(models.Model): blog = models.ForeignKey('weblog.Blog', related_name='+') month = models.DateField() count = models.PositiveIntegerField(default=1) ... class CategoryBasedPrice(models.Model): category = models.CharField( choices=((TECH, 'Tech'), (SPORT, 'Sport'), (FASHION, 'Fashion')), price = models.MoneyField(...) ...
Now for each new view of any blog article, the billing app needs to be informed so that it can record them accordingly. To do so, we can define a signal in the “weblog” app and create a single handler in the “billing” app to process the signal received.
We move the Blog.increase_view_count()into billing/receivers.py as a signal handler:
def increase_view_count(sender, blog, **kwargs): now = timezone.now() month = datetime(year=now.year, month=now.month, day=1) view_count, created = \ MonthlyViewCount.objects.get_or_create( blog=blog, month=month ) if not created: view_count.count += 1 view_count.save()
Then a new signal is created in weblog/signals.py:
from django.dispatch import Signal from billing import receivers ... blog_viewed = Signal(providing_args=['blog']) blog_viewed.connect(receivers.increase_view_count)
And we also need to inject a signal-sending snippet in one of view methods in weblog/views.py:
from weblog.signals import blog_viewed ... def view_blog(request, blog_id): try: blog = Blog.objects.get(blog_id) except ValueError: raise Http404() blog_viewed.send(Blog, blog=blog) response.write(blog.render_content()) return response
Finally we can move the billing-related cron task send_viewcount_payment_to_authors from weblog/tasks.py to billing/tasks.py and add new logic to handle the new category-based pricing.
Although compared with the regular approach, which simply puts everything new into the main app, the approach above needs more code changes and refactoring, it does have several merits that make it worthwhile.
- The business logic from a specific domain is segregated from the other domains, which makes the code base easier to maintain.
- If an issue occurs during the runtime, the cause can be promptly located in the scope of an app based on the symptom. This shortens the debugging time.
- When a new developer onboards, they can start working on a single app first, which will moderate the learning curve.
- If we decide to deprecate the whole set of business logic in a specific domain (e.g. all the billing features are no longer needed), we can simply remove that app and everything else should continue to run normally.
A lot of startups are using Django to prototype their product or service. Additionally, Django can handle the growth of their business pretty well. An important aspect is to rethink and reorganize the business logic into different apps from time to time and keep the responsibility of each app as simple as possible.