# Why does my Django signal break after a refactor?

> Chimera CH-01 (Django): Why does my Django signal break after a refactor?

An order's status field decides which handler runs: a `post_save` receiver builds the function name from the status and calls it through `getattr`. Tests that call the handlers directly keep passing when a handler's signature changes, because the call that went wrong is a string-built name that no test and no type checker connects to the handler. Replace the lookup with an explicit `match` and the call is an ordinary call again.

The code below is from a small example project we wrote for this page. The break below is read from the files, by line.

## 1. The setup

Django does the heavy lifting well here. `post_save` fires after every `Order.save()`, the signal machinery is decoupled from the view that saved the order, and putting side effects in handlers keeps views thin. The shape is common and reasonable.

`orders/models.py` has an `Order` with a `status` field (`draft`, `placed`, `paid`, `shipped`, `refunded`). `orders/handlers.py` has one function per status:

```python
# before/orders/handlers.py
def on_placed(order: Order) -> None: ...
def on_paid(order: Order) -> None: ...
def on_shipped(order: Order) -> None: ...
def on_refunded(order: Order, reason: str) -> None: ...   # line 37
```

`orders/signals.py` picks one:

```python
# before/orders/signals.py
@receiver(post_save)
def dispatch_order_status(sender, instance, **kwargs):
    if not isinstance(instance, Order):
        return
    handler = getattr(handlers, f"on_{instance.status}", None)   # line 12
    if handler is not None:
        handler(instance)                                         # line 14
```

Two test files cover it. `orders/tests/test_handlers.py` calls each handler directly. `orders/tests/test_signals.py` saves orders in the `placed` and `shipped` states and checks the mail outbox.

## 2. Where the call path hides

Three things hide the path from a coding agent or a static tool:

- **The function name is a string.** Line 12 builds `"on_refunded"` at runtime from a database value. Searching for callers of `on_refunded` finds the definition (`handlers.py:37`) and the direct test call (`test_handlers.py:27`). Nothing in the source says `signals.py` reaches it.
- **The call is through a variable.** Line 14 calls `handler(...)`. `handler` came from `getattr` with a default of `None`, so a type checker sees an object of unknown type or `None`, not a function with a signature to compare against.
- **The receiver has no `sender`.** `@receiver(post_save)` connects to every model's save. The `isinstance` check on line 10 filters at runtime, so a reader can't tell from the decorator which model this serves. The module is also loaded as a side effect of the import at the bottom of `models.py`, not from `AppConfig.ready()`, so a tool that follows `INSTALLED_APPS` to `apps.py` finds no mention of it.

The `None` default turns a second kind of edit into a silent one: rename `on_shipped` to `on_dispatched` and the dispatcher skips shipped orders without an error.

## 3. The edit that breaks it, and which call is left wrong

The edit adds a required argument to one handler, so the refund email can say why:

```python
# handlers.py:37, after the edit
def on_refunded(order: Order, reason: str) -> None:
```

The direct test was updated with it (`test_handlers.py:27` passes `"damaged in transit"`), so the handler tests pass. Every other call to `on_refunded` that a search can find is correct.

The call that is still wrong is `before/orders/signals.py:14`:

```python
handler(instance)
```

When an order's status is `refunded`, line 12 resolves `on_refunded`, and line 14 calls it with one argument. Python raises `TypeError` for the missing `reason`, inside the receiver, during `Order.save()`. The refund request fails at the save call. Django's docs say `send()` does not catch exceptions raised by receivers and lets them propagate; the same page says "All built-in signals, except those in the async request-response cycle, are dispatched using `Signal.send()`", and the signals reference says all built-in signals are sent using `send()`, so `post_save` goes through `send()` (https://docs.djangoproject.com/en/stable/topics/signals/ and https://docs.djangoproject.com/en/stable/ref/signals/).

Why no existing test reaches line 14 with a refunded order: `test_signals.py` saves `placed` and `shipped` orders only. The refund test (`test_refunding_an_order_sends_refund_notice`) is written the way it would be written, and shipped marked `@unittest.expectedFailure` with the reason in a comment, so the suite shows the gap instead of passing around it.

## 4. The fix

Replace the string-built name with an explicit `match` in which each branch is an ordinary call, connect the receiver with its `sender`, and load `signals.py` from `AppConfig.ready()`.

```diff
--- before/orders/signals.py
+++ after/orders/signals.py
+from typing import Any
+
 from django.db.models.signals import post_save
 from django.dispatch import receiver

 from orders import handlers
 from orders.models import Order


-@receiver(post_save)
-def dispatch_order_status(sender, instance, **kwargs):
-    if not isinstance(instance, Order):
-        return
-    handler = getattr(handlers, f"on_{instance.status}", None)
-    if handler is not None:
-        handler(instance)
+@receiver(post_save, sender=Order)
+def dispatch_order_status(
+    sender: type[Order], instance: Order, **kwargs: Any
+) -> None:
+    match instance.status:
+        case Order.Status.PLACED:
+            handlers.on_placed(instance)
+        case Order.Status.PAID:
+            handlers.on_paid(instance)
+        case Order.Status.SHIPPED:
+            handlers.on_shipped(instance)
+        case Order.Status.REFUNDED:
+            handlers.on_refunded(instance, instance.refund_reason)
+        case _:
+            pass  # draft: nothing to send
```

```diff
--- before/orders/apps.py
+++ after/orders/apps.py
 class OrdersConfig(AppConfig):
     name = "orders"
+
+    def ready(self) -> None:
+        # Importing the module connects the @receiver functions in it.
+        from orders import signals  # noqa: F401
```

```diff
--- before/orders/models.py
+++ after/orders/models.py
-
-
-# Connect the receivers defined in orders/signals.py.
-from orders import signals  # noqa: E402,F401
```

## 5. Why the fix works

- **Each handler call is a call a tool can see.** `handlers.on_refunded(instance, instance.refund_reason)` at `after/orders/signals.py:22` names the function. A search for callers lists it. Had the edit been made against this file, leaving `handlers.on_refunded(instance)` in place, a type checker compares the call to `def on_refunded(order: Order, reason: str)` in the plain `handlers.py` and reports the missing argument at that line. That holds without Django stubs, because the mismatch is between two ordinary Python functions.
- **A rename can't skip an order silently.** The old `None` default is gone. Renaming `on_shipped` now leaves `handlers.on_shipped(instance)` pointing at a name that no longer exists, which a language server rename updates and a type checker flags.
- **`sender=Order` says which model the receiver serves.** The signals topic in the Django docs shows `@receiver(pre_save, sender=MyModel)` and states the handler is called only when an instance of that model is saved. The `post_save` form works the same way: the signals reference describes `post_save` as "Like `pre_save`, but sent at the end of the `save()` method", with `sender` as the model class; the topic page prints no `post_save` example. The `isinstance` guard is no longer needed.
- **Loading is in one named place.** Django's docs say signal receivers are usually defined in a `signals` submodule and connected in the `ready()` method of the app configuration, and that with the `receiver()` decorator you import the `signals` submodule inside `ready()`. `AppConfig.ready()` is called once the app registry is fully populated.
- **The test now covers the refund path.** `after/orders/tests/test_signals.py` saves a refunded order and checks that the reason is in the email.

The `ready()` docs add one caution that applies here: `ready()` can run more than once in tests that change installed apps, so registration should be safe to repeat. A `@receiver` function connected by import is, because it is connected on first import of the module.

## 6. Rules that apply

- **AR-07**: don't build method names as strings.
- **AR-08**: dispatch over a closed set with a literal-keyed mapping or an explicit `match`. The order status is a closed set.
- **AR-31**: Django signals get an explicit `sender` and live in one `signals.py` per app, imported in `AppConfig.ready()`.
- **AR-25**: before changing a signature, list every caller and every dynamic site that might reach it. The `getattr` site is the one a plain search won't list.

## 7. References

Django (fetched 2026-10-30 from the stable docs):
- Signals topic (where to put receivers, connecting in `ready()`, `@receiver(..., sender=...)`, `dispatch_uid`, `send()` vs `send_robust()`): https://docs.djangoproject.com/en/stable/topics/signals/
- Applications reference (`AppConfig.ready()`, when it runs, registering signals): https://docs.djangoproject.com/en/stable/ref/applications/


## Changelog
- 2026-10-30: verify items resolved (docs check)

## Rules it tests

- [AR-07: Why does my coding agent miss calls made through getattr?](https://beta.llmgraph.in/agent-readable/ar-07.md)
- [AR-08: How do I write dispatch that type checkers can follow?](https://beta.llmgraph.in/agent-readable/ar-08.md)
- [AR-25: What to check before changing a function signature?](https://beta.llmgraph.in/agent-readable/ar-25.md)
- [AR-31: Why does my coding agent miss Django signal receivers?](https://beta.llmgraph.in/agent-readable/ar-31.md)

Last verified 2026-10-30
