skip to content

As a Django team lead, when would you allow model signals and when would you require explicit service-function calls instead?

level: principalimportance: should knowfreq 40%

answer

  1. who owns the sender
  2. hidden control flow
  3. bulk paths are holes
  4. decoupling across app boundaries

basics

~20 s

Use signals when you cannot edit the sender, such as a third-party app or contrib model, or when one app must react to another without importing it. Inside your own code, prefer explicit service calls: visible, testable, and not bypassed by bulk writes.

solid answer

~50 s

My default is explicit calls: a `create_order()` service that saves the order and then updates stock is readable top to bottom, easy to test, and its behaviour does not depend on which apps were loaded. Django's own docs say that if sender and receiver are both in your project you are better off with an explicit call. I allow signals in three cases: reacting to models I do not own (`auth` users, a third-party package), letting a lower-level app notify higher-level apps without importing them, and cross-cutting concerns such as audit logs where every save path must be covered. The cost I make explicit in review: receivers run synchronously inside the caller's transaction, they are skipped by `update()` and bulk writes, and debugging requires knowing they exist. So receivers stay thin, live in `signals.py`, and delegate to a service function.

go deeper

for a junior

Know that a signal is an implicit call triggered by save or delete, and that Django's docs prefer an explicit call inside your own code.

for a middle

Explain the costs concretely: synchronous execution, skipped bulk paths, registration through ready(), and harder debugging.

for a senior

Show a code-review stance: thin receivers delegating to services, no network I/O inline, and explicit handling wherever bulk writes touch signalled models.

for a principal

Set the team rule and defend it: signals for unowned senders, app-boundary notifications and audit; explicit services elsewhere, with custom signals as documented contracts.

## The decision in one sentence A **signal** is an implicit function call: the code that saves a model does not say what else will happen. That indirection is valuable when the sender **cannot or should not know** about the receiver, and a liability when it could simply call it. ## What signals cost a team - **Hidden control flow.** Reading `order.save()` tells you nothing about the three receivers that also run. New team members find them by grepping or by a stack trace. - **Synchronous and transactional.** Receivers run in the caller's thread, inside whatever transaction is open. A slow or failing receiver slows or breaks the save, and side effects run before the commit is certain. - **Holes.** `QuerySet.update()`, `bulk_create()` and `bulk_update()` send no save signals, and `DB_CASCADE` (Django 6.1) sends no delete signals. Logic that "always runs on save" silently does not. - **Order and registration.** Receiver order follows registration order, which follows `INSTALLED_APPS` and import order; a receiver whose module is never imported from `ready()` quietly does nothing. - **Testing friction.** Every test that saves the model pays for the receivers, and tests that want to avoid them need to disconnect them. The Django docs echo this: the signal reference warns that signals can make code harder to maintain and suggests a custom manager method or an overridden model method first, and the signals topic says that if sender and receiver are both in your project, an explicit function call is better. ## When signals are the right tool | Situation | Why a signal fits | |---|---| | Reacting to a model you do not own (`AUTH_USER_MODEL`, a third-party package) | You cannot edit its `save()` or its callers | | A lower-level app notifying higher-level apps | Keeps the dependency direction; the core app does not import billing or search | | A cross-cutting concern that must cover every `save()`, including the admin | One hook instead of editing every caller | | A reusable app exposing extension points | A custom `Signal()` is a documented, public hook for other projects | ## When to require explicit calls - The business action has one or two entry points you own: put it in a **service function** (`place_order()`, `register_user()`) and call the follow-up work directly. - The work touches the network or external systems: it needs explicit error handling and usually belongs after commit or on a queue. - Order matters between steps: explicit calls make the order part of the code, not an accident of registration. ## A team rule that works 1. **Default to explicit service functions** for your own domain actions. 2. **Allowed signals are listed**: sender not owned by us, or an app-boundary notification, or audit. Anything else needs a reason in review. 3. **Receivers are thin**: guard on `created`/`raw`, then call a named service function that tests can call directly. 4. **Receivers live in `signals.py`, connected in `AppConfig.ready()`, with `sender` always set** and a `dispatch_uid` where duplicate registration is possible. 5. **No network I/O inside a receiver**; defer it until commit or hand it to a task. 6. **Bulk paths are documented**: any `update()` or bulk write on a model with receivers calls the service function explicitly. ## Where reasonable leads disagree Some teams ban model signals outright except for third-party senders, accepting a few duplicated calls for full traceability. Others use custom signals heavily as an internal event bus between apps in a modular monolith. The second works only if the signals are named, documented and treated as a contract; model `post_save` is a poor event bus because it fires for technical reasons (every `last_login` update) rather than business events. A custom signal such as `order_placed`, sent explicitly by a service function, keeps the decoupling while making the emitting point visible.

  • How is a custom order_placed signal better than post_save on Order as an integration point?
    A custom signal is sent explicitly by the service that completes a business action, so it fires exactly once per real event and carries meaningful arguments. `post_save` fires on every save for technical reasons, including status tweaks, admin edits and fixtures, and receivers must reverse-engineer intent from `created` and `update_fields`.
  • How do you keep receivers testable under this rule?
    Keep the receiver a thin adapter that calls a named service function. Unit-test the service function directly; one integration test proves the receiver is connected, for example by saving the model and asserting the effect. Tests that must avoid the side effect can mock the service function instead of disconnecting signals.

saying these in an interview costs you the question

  • Uses post_save for logic in the same app just to keep save() short
  • Treats signals as asynchronous events that cannot slow down a request
  • Assumes every write path triggers the receiver, bulk writes included
  • Bans signals even for reacting to third-party models they cannot modify
  • Puts business logic directly inside receiver bodies instead of named functions