dj-signals
Add a Reliable Signal
You are adding a reliable signal to an opinionated Django project. Standard Django signals are synchronous and unreliable — receiver failures propagate to the sender, there's no delivery guarantee if the process crashes after commit, and there's no retry. This project uses the reliable signals pattern with Celery instead.
The pattern implemented here is adapted from Haki Benita's article Reliable Signals in Django. Credit for the original design goes to him — read the full article for the rationale and edge cases.
How It Works
Signal receiver tasks are enqueued inside the same database transaction as the business operation. If the transaction rolls back, the tasks roll back too. If it commits, the tasks are guaranteed to be in the queue. Celery processes them asynchronously with at-least-once delivery.
BEFORE WRITING CODE
- Read
ARCHITECTURE.mdif present for the full reliable signals reference - Find existing signals with
GrepforReliableSignalundersrc/ - Find existing receivers with
Globforsrc/**/receivers.py - Identify which service method should emit the signal and what data needs to travel with it
Step 1: Define the Signal
File: src/<app>/signals.py
from project.signals import ReliableSignal
my_event = ReliableSignal()
Step 2: Send from the Service Layer
Call send_reliable() inside a transaction.atomic() block. Arguments MUST be JSON-serializable — pass entity IDs, never model instances:
from django.db import transaction
def create_entity(self, name: str) -> MyEntityDTO:
with transaction.atomic():
entity = self.repo.create(name=name)
my_event.send_reliable(sender=None, entity_id=entity.id)
return entity
Step 3: Write the Receiver
File: src/<app>/receivers.py
from django.dispatch import receiver
from .signals import my_event
@receiver(my_event)
def on_my_event(obj_id: str, **kwargs):
# Idempotent: guard against duplicate execution
if already_processed(obj_id):
return
do_work(obj_id)
CRITICAL: Every receiver MUST be idempotent. The system guarantees at-least-once delivery, not exactly-once. A receiver may run more than once for the same event. Design accordingly:
- Check if the action was already performed before performing it
- Use database constraints or flags to prevent duplicate effects
- Never assume a receiver runs exactly once
Step 4: Load Receivers in apps.py
class MyAppConfig(AppConfig):
def ready(self):
from . import receivers # noqa: F401
Step 5: Test
Test receivers in isolation. Mock external dependencies. Verify idempotency by calling the receiver twice with the same arguments:
def test_receiver_is_idempotent():
on_my_event(obj_id="xxx_fake")
on_my_event(obj_id="xxx_fake") # second call must be safe
# assert side-effect happened exactly once
Rules
- NEVER use standard Django
send()for post-commit side-effects — usesend_reliable() - Arguments MUST be JSON-serializable (strings, numbers, booleans) — never model instances
- Receivers MUST be idempotent — this is non-negotiable
- Receivers MUST NOT import or touch ORM models directly — use a repository if DB access is needed
- Receivers MUST NOT call other services that emit signals (no cascading) without careful consideration of idempotency across the chain
VERIFY
uv run ruff check src
uv run ruff format --check src
uv run pyrefly check src
uv run pytest
If anything fails, fix it and re-run.
More from dvf/opinionated-django
services
Structure Django business logic as plain services that receive their dependencies via constructor injection, and wire them through an svcs registry so they can be resolved anywhere — views, Celery tasks, management commands, tests. Use when adding a new service, refactoring fat views or model methods into a service, wiring a service into the registry, or explaining where business logic should live in this project.
11architecture
Implement a Django feature following the opinionated architecture — prefixed ULID IDs, repository pattern, Pydantic DTOs, svcs service locator, project-scoped django-ninja API, Celery reliable signals, and layered tests. Use when the user asks to add a new entity, endpoint, app, or business logic in a Django project that follows these conventions.
11lint
Run linting, formatting, and static type checks on a Django project using ruff and pyrefly, and fix any issues found. Use after making code changes, before committing, or whenever the user asks to lint, format, or type-check the codebase.
10pytest
Set up and write pytest tests for an op-django project — pytest-django configuration, Celery eager mode for reliable-signal tests, freezegun for time-sensitive logic, shared conftest fixtures for DTOs and svcs overrides, and the three-layer test convention (repository against a real DB, service against mocked repos, API through HTTP). Use when adding tests to a new project, writing tests for a new feature, setting up test infrastructure, or explaining how tests should be organized.
10scaffold
Set up a Django project into the op-django layout so the architecture, signals, and settings skills have a foundation to build on. Use when starting a new project from scratch, or when converting an existing Django project to follow this opinionated structure. Creates the src/project/ shell (ids, services registry, api, reliable signals), installs dependencies with uv, and establishes the per-app directory conventions.
10signals
Add reliable signals (async side-effects via Celery) to a Django feature. Use when a business operation needs to trigger post-commit work like notifications, cache invalidation, analytics, or cross-service coordination — any time the user mentions side-effects, events, or async processing tied to a business action.
10