Fat Django Models? Here’s a Cleaner Way to Organise Your Logic
Use Django signals to move logic across app boundaries — without creating tight coupling.
In any well-structured project, clearly defined module boundaries are essential for maintainability and scalability. By “module boundary,” I mean a clear separation between different modules and how different modules communicate with each other.
To make this happen, modules should have a clear interface for communication. This interface defines how others can interact with the module and what information they can expect from it. Outside of that, everything else is kept private.
Examples of such boundaries can be seen in REST APIs. When interacting with external API services, we receive a list of endpoints and possibly webhooks. Whatever happens inside the external API is a black box. We put trust in the external API to do its best.
This allows teams to work independently on different modules without needing to understand the internal workings of others. This enables us to iterate faster on things that matter most.
Without these boundaries, applications can become tightly coupled, making them difficult to understand, test, and modify.
Django offers the ability to have separate apps, which can be seen as modules. However, we are still lacking the separation of side effects. Apps have to be coupled together, and each app (and possibly teams) has to understand the internal workings of others. This is where Django signals come in. A way to decouple domain logic from side effects.
Django Signals as a delivery mechanism
To notify other modules of what has happened within a module, we need to have a delivery method. There are various ways to achieve this, for example, you can use Webhooks or an Email. Of course, neither of those applies to the in-process notifications. This is where Django Signals come in.
The way signals work is relatively straightforward. Think of signals like a radio station broadcast — when something happens, any module that’s ‘tuned in’ can respond.
Similar applies here, an action happens in a module, the outcome of which we want to make public. This is when a signal will be triggered. In a different module, we will have a receiver that listens for the signal. The receiver will pick up on the fact that the signal was triggered and execute the necessary code.
This is how it looks in practice
We defined a signal that will be triggered in a relevant function. In our case, we will trigger it after a webhook from a 3rd party is processed.
# modules/payments/signals.py
# register signal
payment_was_confirmed = Signal()
# modules/payments/actions.py
from modules.payments.signals import payment_was_confirmed
def stripe_webhook_handler():
# payment was successfully made
# trigger signal
payment_was_confirmed.send_robust(sender=FooBar, payment=payment)In a different module, we define a receiver. Notice that the billing module now depends on the payments module. When a signal is triggered, a call to store_confirmed_payment_for_billing is made.
# modules/billing/receivers.py
from modules.payments.signals import payment_was_confirmed
# When the signal is triggered, the receiver will execute the function
@receiver(payment_was_confirmed)
def store_confirmed_payment_for_billing(sender, **kwargs):
# do something billing related
passBy using this approach, we end up with a clear dependency between the two modules. In addition, we can have as many receivers as we want, across different modules, which makes extending existing functionality a breeze.
Benefits of using signals for decoupling
A beautiful thing about signals is that Django includes them out of the box, and there’s very little you need to learn to get started. You don’t need to create anything custom, you can get going right away!
Signals create a clear communication boundary, and there is a way for external modules to subscribe to specific signals within that boundary.
You can append multiple receivers to a signal, which helps with extending existing code.
This also makes it easier to test both the domain logic and the orchestration independently.
Conclusion
Boundaries are important for any application. How those boundaries are implemented is critical. With Django offering signals out of the box, there is no excuse not to have strict boundaries. With boundaries in place, we can move faster and reduce friction when doing cross-team collaboration.
But, nothing comes free! Boundaries have to be clearly defined and stuck to, but this is beyond the scope of this article. Challenges also extend to Django signals; there are various caveats, such as difficulty debugging and being synchronous, but, under the right conditions, they are a useful boundary tool.
This is the first in a short series where I share examples of using signals to enforce boundaries and keep logic where it belongs. If you’ve ever been tempted to reach across layers to ‘just call a method’ — this is for you.



