Skip to main content

django-domain-events

CI PyPI Python versions Django versions Docs Coverage

A Django domain-event log with in-process fan-out.

fire() records a typed event to a database table inside the caller's transaction; a relay delivers it to registered receivers afterwards, at-least-once, with per-receiver retry and dead-lettering. The event exists if and only if the change committed.

This is not a signals replacement. A database write per event rules out chatty notification use, and buys three things signals cannot give you: a crash story, durable attribution for who caused what, and an event log you can query.

Install

pip install django-domain-events

Add it to INSTALLED_APPS and migrate:

INSTALLED_APPS = [..., "django.contrib.auth", "django_domain_events"]

django.contrib.auth is required: the event row carries a nullable foreign key to AUTH_USER_MODEL so attribution survives, and the migration depends on it.

python manage.py migrate

Nested event payloads need the decode half of the codec:

pip install "django-domain-events[dacite]"

Quickstart

Declare an event and something that listens for it:

# orders/events.py
from dataclasses import dataclass

from django_domain_events import DURABLE, event, receiver


@event
@dataclass(frozen=True, slots=True)
class OrderPlaced:
    order_id: int
    total_cents: int


@receiver(OrderPlaced, mode=DURABLE)
def reserve_stock(evt: OrderPlaced) -> None: ...

Fire it inside the transaction that makes the change:

with transaction.atomic():
    order = Order.objects.create(...)
    fire(OrderPlaced(order_id=order.id, total_cents=order.total_cents))

The event row and one delivery row per durable receiver are written in that same transaction. Run the relay to deliver what is owed:

python manage.py deliver_events          # claim and deliver continuously
python manage.py deliver_events --once   # one pass, for cron or CI

The relay claims with SELECT ... FOR UPDATE SKIP LOCKED under a lease, so you can run as many as you like: two workers never take the same row, and one that dies without acknowledging has its rows reclaimed when the lease lapses. Failed deliveries retry with exponential backoff and full jitter, then dead-letter.

Add eager=True to a receiver to also attempt it immediately after commit, in the firing process, with the relay as the fallback.

In tests, drain_outbox() runs the real delivery path to completion, and assert_fired(OrderPlaced, times=1) reads the log rather than a mock.

Operations

python manage.py prune_events                 # delete settled events past the window
python manage.py replay_events 41 42          # make those events owed again
python manage.py requeue_dead --receiver k    # give dead deliveries their budget back

Pruning only removes settled events: one with a delivery still owed is kept, because deleting it would drop work nothing recorded as lost.

On Postgres the relay waits on LISTEN/NOTIFY rather than polling, so an event fired a moment ago is delivered in milliseconds. The poll remains the floor.

Add django.contrib.admin and both tables appear, read-only, with Replay selected events and Requeue selected dead deliveries as actions.

Introspection

The second reason to use this rather than signals. All of it is generated from the declarations, so none of it can drift.

python manage.py export_catalogue --format json --output events.json
python manage.py quiet_receivers --days 30
what_listens_to(OrderPlaced)  # every receiver, sorted, across all modes
listens_for("orders.reserve_stock")  # the inverse: what a dead row was owed
quiet_receivers(within=timedelta(days=30))

The catalogue is every declared event, its payload schema and its receivers, as Markdown for a person or JSON for a pipeline that fails a pull request when a field other teams consume disappears.

quiet_receivers() answers "this receiver has not received anything in ninety days" as a query rather than a guess - including the receivers that have never received anything at all, which is the answer worth having and the one a query over delivery rows alone cannot produce.

manage.py check adds four checks, including one for the renamed event: the receivers keep their keys, so nothing looks orphaned, while every row written under the old name now decodes to nothing.

Attribution

with attributed(actor=request.user, source="checkout"):
    with transaction.atomic():
        order = Order.objects.create(...)
        fire(OrderPlaced(order_id=order.id, total_cents=order.total_cents))

Every event fired inside the block records who caused it, in what scope, and which chain it belongs to. The scope is captured at fire time and read back off the row, so a delivery running hours later in another process still knows.

Suppress without losing the record:

with suppressed(OrderPlaced, reason="historical import"):
    importer.run()  # rows written and marked, no deliveries

Delivery modes

Two independent knobs, not one enum. Timing is what a receiver promises about the transaction; where its code runs is a separate question, and only meaningful for DURABLE.

Mode Runs Can veto Recoverable
INLINE inside the transaction yes, by raising not needed: its failure is a rollback
ON_COMMIT after commit, in the firing process no no
DURABLE (default) after commit, at-least-once, retried no yes

For a receiver that touches only this database, the work and the acknowledgement commit together, so delivery is effectively once: the duplicate an at-least-once system owes you cannot be observed. Receivers with side effects outside the database are at-least-once, as promised.

Documentation

https://artui.github.io/django-domain-events/

Status

Early development, and usable: the contract, the relay, ambient scope, the operations surface and introspection have all landed. The API is not stable until 1.0 - see the changelog for what changed in each release.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_domain_events-0.6.0.tar.gz (236.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_domain_events-0.6.0-py3-none-any.whl (84.9 kB view details)

Uploaded Python 3

File details

Details for the file django_domain_events-0.6.0.tar.gz.

File metadata

  • Download URL: django_domain_events-0.6.0.tar.gz
  • Upload date:
  • Size: 236.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for django_domain_events-0.6.0.tar.gz
Algorithm Hash digest
SHA256 56b2cd0c8f18043da84fac72eb8b967df6b4f6bf0a029d9d79f75acf63a9e70e
MD5 c53f1a994a70e512b246f5cb616dc876
BLAKE2b-256 6ac973b788d4beb4a537930ce3b3cc572f2801567fa3a9d5f413a93203b53aba

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_domain_events-0.6.0.tar.gz:

Publisher: release.yml on Artui/django-domain-events

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_domain_events-0.6.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_domain_events-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 fc33801fe10c4bfb31cb58bb3b54ba3f0dd972a2237bbbb67d2ae6f74b564a33
MD5 1688b3258196ed3e13a1ef7e69343e20
BLAKE2b-256 96a0fce4108d5b69d2bfa0c8dc83d995b109d20e30548857bc8b01428693bf64

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_domain_events-0.6.0-py3-none-any.whl:

Publisher: release.yml on Artui/django-domain-events

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.6.0 This release

2 files

0.5.1

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page