Using Django Signals to Simplify and Decouple Code

Introduction

Systems are getting more complex as time goes by and this warrants the need to decouple systems more. A decoupled system is easier to build, extend, and maintain in the long run since not only does decoupling reduce the complexity of the system, each part of the system can be managed individually. Fault tolerance has also enhanced since, in a decoupled system, a failing component does not drag down the entire system with it.

Django is a powerful open-source web framework that can be used to build large and complex systems, as well as small ones. It follows the model-template-view architectural pattern and it is true to its goal of helping developers achieve the delivery of complex data-driven web-based applications.

Django enables us to decouple system functionality by building separate apps within a project. For instance, we can have a shopping system and have separate apps that handle accounts, emailing of receipts, and notifications, among other things.

In such a system, several apps may be need to perform an action when certain events take place. One event can occur when a customer places an order. For example, we will need to notify the user via email and also send the order to the supplier or vendor, at the same time we can be able to receive and process payments. All these events happen at the same time and since our application is decoupled, we need to keep every component in sync, but how do we achieve this?

Django Signals come in handy in such a situation, all that needs to happen is a signal is sent when a user places an order, and every related or affected component listens out for it and performs its operations. Let us explore more about signals in this post.

Signals at a Glance

Django Signals are an implementation of the Observer Pattern. In such a design pattern, a subscription mechanism is implemented where multiple objects are subscribed to, or "observing", a particular object and any events that may happen to it. A good analogy is how all subscribers to a YouTube channel get a notification when a content creator uploads new content.

Through a "signal dispatcher", Django is able to distribute signals in a decoupled setup to registered "receivers" in the various system components. Signals are registered and triggered whenever certain events occur, and any listeners to that event will get notified that event has occurred, alongside receiving some contextual data within the payload that may be relevant to the functionality of the receiver. A receiver can be any Python function or method. More on this later on.

Aside from the signals dispatcher, Django also ships with some useful signals that we can listen on. They include:

  • post_save, which is sent out whenever a new Django model has been created and saved. For instance, when a user signs up or uploads a new post,
  • pre_delete, which is sent out just before a Django model is deleted. A good scenario would be when a user is deleting a message or their account,
  • request_finished, which is fired whenever Django completes serving an HTTP request. This can range from opening the website or accessing a particular resource.

Another advantage of Django is that it is a highly customizable framework. In our case, we can create our custom signals and use the built-in system to dispatch and receive them in our decoupled system. In the demo section, we will subscribe to some of Django's built-in signals, and also create some custom ones of our own.

But first, let's see a quick example that utilizes Django Signals. Here we have two functions that play ping-pong with each other, but interact through signals:

from django.dispatch import Signal, receiver

# Create a custom signal
ping_signal = Signal(providing_args=["context"])

class SignalDemo(object):
    # function to send the signal
    def ping(self):
        print('PING')
        ping_signal.send(sender=self.__class__, PING=True)

# Function to receive the signal
@receiver(ping_signal)
def pong(**kwargs):
    if kwargs['PING']:
        print('PONG')

demo = SignalDemo()
demo.ping()

In this simple script we have created a class with a method to send the signal and a separate function outside the class that will receive and respond. In our case, the signal sender will send the PING command along with the signal, and the receiver function will check if the PING command is present and print out PONG in response. The signal is created with Django's Signal class, and it is received by any function that has the @receiver decorator.

The output of the script:

$ python signal_demo.py

PING
PONG

Normally, we would have to invoke the pong() function from within the ping() function, but with signals, we can obtain a similar but decoupled solution. The pong() function can now reside in another file project and still respond to our PING signal.

When to Use Signals

We have already identified what Django Signals are and how they work, but as is with any other framework feature, it is not meant to be used at every turn. There are particular scenarios where it is highly recommended that we use Django signals, and they include:

  • When we have many separate pieces of code interested in the same events, a signal would help distribute the event notification as opposed to us invoking all of the different pieces of code at the same point, which can get untidy and introduce bugs
  • We can also use Django signals to handle interactions between components in a decoupled system as an alternative to interaction through RESTful communication mechanisms
  • Signals are also useful when extending third-party libraries where we want to avoid modifying them, but need to add extra functionality

Advantages of Signals

Django Signals simplify the implementation of our decoupled systems in various ways. They help us implement reusable applications and instead of reimplementing functionality severally, or modifying other parts of the system, we can just respond to signals without affecting other code. This way, components of a system can be modified, added, or removed without touching the existing codebase.

Signals also provide a simplified mechanism for keeping different components of a decoupled system in sync and up to date with each other.

Demo Project

In our demo project, we will build a simple jobs board where users will access the site, view available jobs and pick a job posting to subscribe to. The users will subscribe just by submitting their email address and will be notified of any changes to the job. For instance, if the requirements change, the job opening is closed, or if the job posting is taken down. All of these changes will be performed by an admin who will have a dashboard to create, update, and even take down job postings.

In the spirit of decoupling our application, we will build the main Jobs Board application and a separate Notifications application that will be tasked with notifying users whenever required. We will then use signals to invoke functionality in the Notifications app from the main Jobs Board app.

Another testament of Django's expansive feature-set is the built-in administration dashboard that our administrators will use to manage jobs. Our work on that front is greatly reduced and we can prototype our application faster.

Project Setup

It is good practice to build Python projects in a virtual environment so that we work in an isolated environment that does not affect the system's Python setup, so we'll use Pipenv.

Let us first set up our environment:

# Set up the environment
$ pipenv install --three

# Activate the virtual environment
$ pipenv shell

# Install Django
$ pipenv install django

Django comes with some commands that help us perform various tasks such as creating a project, creating apps, migrating data, and testing code, among others. To create our project:

# Create the project
$ django-admin startproject jobs_board && cd jobs_board

# Create the decoupled applications
$ django-admin startapp jobs_board_main
$ django-admin startapp jobs_board_notifications

The commands above will create a Django project with two applications within it, which are decoupled from each other but can still work together. To confirm that our setup was successful, let us migrate the default migrations that come with Django and set up our database and tables:

$ python manage.py migrate
$ python manage.py runserver

When we access the local running instance of our Django project, we should see the following:

django set up

This means we have set up our Django project successfully and can now start implementing our logic.

Implementation

Django is based on a model-view-template architecture pattern, and this pattern will also guide our implementation. We will create models to define our data, then implement views to handle data access and manipulation, and finally templates to render our data to the end-user on the browser.

To have our applications integrated into the main Django application, we have to add them to the jobs_board/settings.py under INSTALLED_APPS, as follows:

INSTALLED_APPS = [
    # Existing apps remain...

    # jobs_board apps
    'jobs_board_main',
    'jobs_board_notifications',
]

Part 1: The Main Jobs Board App

This is where the bulk of our system's functionality will reside and it will be the interaction point with our users. It will contain our models, views and templates and some bespoke signals that we will use to interact with the Notifications app.

Let us start by creating our models in jobs_board_main/models.py:

# jobs_board_main/models.py

class Job(models.Model):
    company = models.CharField(max_length=255, blank=False)
    company_email = models.CharField(max_length=255, blank=False)
    title = models.CharField(max_length=255, blank=False)
    details = models.CharField(max_length=255, blank=True)
    status = models.BooleanField(default=True)
    date_created = models.DateTimeField(auto_now_add=True)
    date_modified = models.DateTimeField(auto_now=True)

class Subscriber(models.Model):
    email = models.CharField(max_length=255, blank=False, unique=True)
    date_created = models.DateTimeField(auto_now_add=True)
    date_modified = models.DateTimeField(auto_now=True)

class Subscription(models.Model):
    email = models.CharField(max_length=255, blank=False, unique=True)
    user = models.ForeignKey(Subscriber, related_name="subscriptions", on_delete=models.CASCADE)
    job = models.ForeignKey(Job, related_name="jobs", on_delete=models.CASCADE)
    date_created = models.DateTimeField(auto_now_add=True)
    date_modified = models.DateTimeField(auto_now=True)

We create a model to define our Job posting, which will only have a company name and the job details alongside the status of the job opening. We will also have a model to store our subscribers by only taking their email addresses. The Subscribers and the Jobs come together through the Subscription model where we will store details on subscriptions to Job postings.

With our models in place, we need to make migrations and migrate them to have the tables created in the database:

$ python manage.py makemigrations
$ python manage.py migrate

Next we move on to the view section of our application. Let's create a view to display all job postings, and another to display individual job postings where users can subscribe to them by submitting their emails.

We will start by creating the view that will handle the displaying of all our jobs:

# jobs_board_main/views.py

from .models import Job

def get_jobs(request):
    # get all jobs from the DB
    jobs = Job.objects.all()
    return render(request, 'jobs.html', {'jobs': jobs})

For this project we will use function-based views, the alternative being class-based views, but that is not part of this discussion. We query the database for all the jobs and respond to the request by specifying the template that will render the jobs and also including the jobs in the response.

Django ships with the Jinja templating engine which we will use to create the HTML files that will be rendered to the end-user. In our jobs_board_main application, we will create a templates folder that will host all the HTML files that we will render to the end-users.

The template to render all the jobs will display all jobs with links to individual job postings, as follows:

<!-- jobs_board_main/templates/jobs.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Jobs Board Homepage</title>
  </head>
  <body>
    <h2> Welcome to the Jobs board </h2>

    {% for job in jobs %}
      <div>
        <a href="/jobs/{{ job.id }}">{{ job.title }} at {{ job.company }}</a>
        <p>
          {{ job.details }}
        </p>
      </div>
    {% endfor %}

  </body>
</html>

We have created the Job model, the get_jobs view to get and display all the views, and the template to render the jobs listing. To bring all this work together, we have to create an endpoint from which the jobs will be accessible, and we do so by creating a urls.py file in our jobs_board_main_application:

# jobs_board_main/urls.py

from django.urls import path
from .views import get_jobs

urlpatterns = [
    # All jobs
    path('jobs/', get_jobs, name="jobs_view"),
]

In this file, we import our view, create a path and attach our view to it. We will now register our applications URLs in the main urls.py file in the jobs_board project folder:

# jobs_board/urls.py

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('jobs_board_main.urls')), # <--- Add this line
]

Our project is ready to be tested now. This is what we get when we run the application and navigate to localhost:8000/jobs:

jobs board landing 1

We currently have no jobs in place. Django ships with an administration application that we can use to perform our data entry. First, we start by creating a superuser:

create superuser

With the superuser created, we need to register our models in the admin.py file in our jobs_board_main application:

# jobs_board_main/admin.py
from django.contrib import admin
from .models import Job

# Register your models here.
admin.site.register(Job)

We restart our application and navigate to localhost:8000/admin and log in with the credentials we just set up. This is the result:

jobs board admin 1

When we click on the plus sign on the "Jobs" row, we get a form where we fill in details about our job posting:

jobs data entry

When we save the job and navigate back to the jobs endpoint, we are greeted by the job posting that we have just created:

jobs board landing 2

We will now create the views, templates, and URLs to display a single job and also allow users to subscribe by submitting their email.

Our jobs_board_main/views.py will be extended as follows:

# jobs_board_main/views.py
# previous code remains
def get_job(request, id):
    job = Job.objects.get(pk=id)
    return render(request, 'job.html', {'job': job})

def subscribe(request, id):
    job = Job.objects.get(pk=id)
    sub = Subscriber(email=request.POST['email'])
    sub.save()

    subscription = Subscription(user=sub, job=job)
    subscription.save()

    payload = {
      'job': job,
      'email': request.POST['email']
    }
    return render(request, 'subscribed.html', {'payload': payload})

We will also need to create the template for a single view of a job posting in templates/job.html, which includes the form that will take in a user's email and subscribe them to the job posting:

<!-- jobs_board_main/templates/job.html -->
<html>
  <head>
    <title>Jobs Board - {{ job.title }}</title>
  </head>
  <body>
      <div>
        <h3>{{ job.title }} at {{ job.company }}</h3>
        <p>
          {{ job.details }}
        </p>
        <br>
        <p>Subscribe to this job posting by submitting your email</p>
        <form action="/jobs/{{ job.id }}/subscribe" method="POST">
          {% csrf_token %}
          <input type="email" name="email" id="email" placeholder="Enter your email"/>
          <input type="submit" value="Subscribe">
        </form>
        <hr>
      </div>
  </body>
</html>

Once a user subscribes to a job, we will need to redirect them to a confirmation page whose subscribed.html template will be as follows:

<!-- jobs_board_main/templates/subscribed.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Jobs Board - Subscribed</title>
  </head>
  <body>
      <div>
        <h3>Subscription confirmed!</h3>
        <p>
          Dear {{ payload.email }}, thank you for subscribing to {{ payload.job.title }}
        </p>
      </div>
  </body>
</html>

Finally, our new functionality will need to exposed via endpoints which we will append to our existing jobs_board_main/urls.py as follows:

# jobs_board_main/urls.py
from .views import get_jobs, get_job, subscribe

urlpatterns = [
    # All jobs
    path('jobs/', get_jobs, name="jobs_view"),
    path('jobs/<int:id>', get_job, name="job_view"),
    path('jobs/<int:id>/subscribe', subscribe, name="subscribe_view"),
]

We can now test our main Jobs Board application by viewing the job posts listings, clicking on one, and submitting an email address that will receive updates.

Now that we have a working application, it is time to bring in Django Signals and notify users/subscribers when certain events take place. Job postings are tied to a certain company whose email we record, we want to notify them when a new user subscribes to their job posting. We also want to notify subscribed users when a job posting is taken down.

To notify users when a job posting is taken down or deleted, we will utilize Django's built-in post_delete signal. We will also create our signal called new_subscriber that we will use to notify companies when users subscribe to their job posting.

We create our custom signals by creating a signals.py file in our jobs_board_main application:

# jobs_board_main/signals.py
from django.dispatch import Signal

new_subscriber = Signal(providing_args=["job", "subscriber"])

That's it! Our custom signal is ready to be invoked after a user has successfully subscribed to a job posting as follows in our jobs_board_main/views.py file:

# jobs_board_main/views.py

# Existing imports and code are maintained and truncated for brevity
from .signals import new_subscriber

def subscribe(request, id):
    job = Job.objects.get(pk=id)
    subscriber = Subscriber(email=request.POST['email'])
    subscriber.save()

    subscription = Subscription(user=subscriber, job=job, email=subscriber.email)
    subscription.save()

    # Add this line that sends our custom signal
    new_subscriber.send(sender=subscription, job=job, subscriber=subscriber)

    payload = {
      'job': job,
      'email': request.POST['email']
    }
    return render(request, 'subscribed.html', {'payload': payload})

We don't have to worry about the pre_delete signal as Django will send that for us automatically just before a job posting is deleted. The reason we are using pre_delete and not post_delete signal is because, when a Job is deleted, all linked subscriptions are also deleted in the process and we need that data before they are also deleted.

Let us now consume the signals that we have just sent in a separate jobs_board_notifications app.

Part 2: The Jobs Board Notifications App

We already created the jobs_board_notifications application and connected it to our Django project. In this section, we will consume the signals sent from our main application and send out the notifications. Django has built-in functionality to send out emails, but for development purposes, we will print the messages to the console instead.

Our jobs_board_notifications application does not need user interaction, therefore, we do not need to create any views or templates for that purpose. The only goal is for our jobs_board_notifications is to receive signals and send out notifications. We will implement this functionality in our models.py since it gets imported early when the application is starting.

Let us receive our signals in our jobs_board_notifications/models.py:

# jobs_board_notifications/models.py.
from django.db.models.signals import pre_delete
from django.dispatch import receiver

from jobs_board_main.signals import new_subscriber
from jobs_board_main.models import Job, Subscriber, Subscription

@receiver(new_subscriber, sender=Subscription)
def handle_new_subscription(sender, **kwargs):
    subscriber = kwargs['subscriber']
    job = kwargs['job']

    message = """User {} has just subscribed to the Job {}.
    """.format(subscriber.email, job.title)

    print(message)

@receiver(pre_delete, sender=Job)
def handle_deleted_job_posting(**kwargs):
    job = kwargs['instance']

    # Find the subscribers list
    subscribers = Subscription.objects.filter(job=job)

    for subscriber in subscribers:
        message = """Dear {}, the job posting {} by {} has been taken down.
        """.format(subscriber.email, job.title, job.company)

        print(message)

In our jobs_board_notifications, we import our custom signal, the pre_save signal, and our models. Using the @receiver decorator, we capture the signals and the contextual data passed with them as keyword arguments.

Upon receiving the contextual data, we use it to dispatch the "emails" (recall that we're just printing to the console for the sake of simplicity) to subscribers and companies when a user subscribes and a job posting is deleted by responding to the signals we sent out.

Testing

Once we have created a job in our admin dashboard, it is available for users to subscribe. When users subscribe, the following email is sent out from the jobs_board_notifications application to the company that owns the posting:

job subscribe message

This is proof that our new_subscriber signal was sent out from the jobs_board_main application and received by the jobs_board_notifications application.

When a job posting is deleted, all users who subscribed to the job posting get notified via email, as follows:

job deleted email

Django's pre_delete signal came in handy and our handler sent out notifications to the subscribed users that the particular job posting has been taken down.

Summary

In this article we have built a Django project with two applications which communicate through Django Signals in response to certain events. Our two applications are decoupled and the complexity in communication between our applications has been greatly reduced. When a user subscribes to a job posting, we notify the company. In turn, when a job posting has been deleted, we notify all subscribed customers that the job posting has been taken down.

However, there are some things we should have in mind when utilizing Django Signals. When signals are not well documented, new maintainers may have a hard time identifying the root cause of certain issues or unexpected behavior. Therefore, when signals are used in an application, it is a good idea to document the signals used, where they are received, and the reason behind them. This will help anyone maintaining the code to understand application behavior and solve issues faster and better. Also, it is helpful to note that signals are sent out synchronously. They are not executed in the background or by any asynchronous jobs.

With all this information about Django's Signals and the demo project, we should be able to harness the power of Signals in our Django web projects.

The source code for this project is available here on Github.

Author image
About Robley Gori
Nairobi, Kenya Twitter Website