This release is a pre-release and may not be stable for production use.
django-event-bus
English · Français
Release candidate.
django-event-busis at1.0.0rc3: the API is considered frozen but has not yet been battle-tested by real-world usage outside this repository. Feedback (issues, use cases, bugs) is welcome before the final1.0.0is tagged.
The problem this solves
In a Django microservices architecture (service_auth, service_order,
service_product, ...), two needs keep coming up:
- Notify other services that a business event happened (a user created, an order paid, ...) — without each service having to configure Redis/Kafka itself, or know who's listening.
- Read data owned by another service from its PK (a user's email to display an order, for instance) — without hand-writing an HTTP client or knowing that service's URL.
django-event-bus answers both with the same philosophy as Django
itself: declarative configuration in settings.py (EVENT_BUS,
REMOTE_DATA — like DATABASES or CACHES) and auto-discovered file
conventions (events.py, resources.py — like admin.py), so a service
has almost nothing but declarations to write, never network plumbing.
This README is a guided tour: each section explains why a piece exists
before showing how to use it. The example/ folder contains two real
Django mini-projects (service_auth, service_order) that run
everything described here — the
Two-service demo section gives
the exact commands to run.
Installation
INSTALLED_APPS = [
...,
"django_event_bus",
]
That's it for installation: no migration of its own, no model. The rest
of this README explains the two optional settings (EVENT_BUS,
REMOTE_DATA) and the file conventions they activate.
1. Emitting and receiving events
Configuring the bus
EVENT_BUS = {
"SERVICE_NAME": "service_auth", # required: identifies this service in logs/errors
"BACKEND": "django_event_bus.brokers.redis_streams.RedisStreamsBroker",
"OPTIONS": {"URL": "redis://localhost:6379/0"},
}
BACKEND is the only place where the broker is chosen. The default
backend, django_event_bus.brokers.locmem.LocMemBroker, needs no infra
(useful in tests/dev — it's what's used if you don't set EVENT_BUS at
all, except SERVICE_NAME which stays required). Switching to Redis, or
tomorrow to another broker implementing the same interface, changes
only this dict — no application code to touch.
Emitting an event
# accounts/events.py
from django_event_bus import RemoteSignal
# Declared once at module level, like a django.dispatch.Signal.
user_created = RemoteSignal("auth.user_created")
# Then, anywhere (a view, a post_save signal, a task, ...):
user_created.send(payload={"id": user.id, "email": user.email})
The "{service}.{event}" naming convention (auth.user_created) avoids
collisions between services: each one prefixes its events with its own
name.
Receiving an event (in another service)
# orders/events.py
from django_event_bus import receiver
@receiver("auth.user_created")
def on_user_created(payload, envelope, **kwargs):
...
Any events.py file in an installed app is auto-discovered at startup
(the same mechanism as admin.autodiscover() — nothing to manually
import elsewhere). Subscription happens by event name
("auth.user_created"), never by importing the emitting service's
RemoteSignal object: service_order generally has no access to
service_auth's code, so that would be impossible anyway. This rule —
subscribing to a name, not a Python object — is what lets two services
talk without knowing each other.
Consuming events: the worker
python manage.py eventbus_worker
Starts a blocking worker that consumes the event_types with a local
@receiver in this service, runs the receivers, acknowledges on success,
retries on failure, then moves the event to dead-letter ({stream}:dlq)
after MAX_RETRIES attempts (Redis backend). It's a separate process,
meant to run continuously (like a Celery worker) — one per service that
has at least one @receiver.
RemoteSignal.send() and transactions
send() publishes via transaction.on_commit(): if the call happens
inside a transaction (typically a post_save), the event is only sent
after the actual commit — never for a row whose write was ultimately
rolled back. Outside a transaction, it's sent immediately.
Consequence for tests: with pytest.mark.django_db alone (implicit
rollback at the end of the test), the callback never fires — use
pytest.mark.django_db(transaction=True) (or the pytest-django
django_capture_on_commit_callbacks fixture) for any test that checks
an event was actually emitted.
What the library guarantees — and what it doesn't
The bus is at-least-once: an event can be delivered more than once
to a @receiver (redelivery after a fail(), or after a worker crash
before the ack). Write idempotent receivers (e.g. get_or_create
instead of create, or a unique key on envelope.event_id).
transaction.on_commit() avoids "phantom" events (published for data
that was never committed) but doesn't protect against a broker outage
at the exact moment of commit: in that case the DB write stays
committed but the publish fails and surfaces as a post-commit error. For
a stronger zero-loss guarantee, the correct solution is a transactional
outbox pattern (local table + separate relay) — out of scope for this
version.
2. Reading data owned by another service (RemoteForeignKey)
The problem, concretely
service_order needs to display an order's customer email. The user
lives in service_auth. Without this library, you'd have to write an
HTTP client, know service_auth's URL, handle caching and network
failures by hand, and redo all of that for every resource.
RemoteForeignKey does all of it from a single declaration on the
model, the way a regular ForeignKey would for a local relation.
Configuring where the other services are
REMOTE_DATA = {
"SERVICE_REGISTRY": {
"service_auth": {
"http": {"base_url": "http://service-auth:8000/api", "timeout": 3},
"grpc": {"target": "service-auth:50051", "timeout": 3},
},
},
"DEFAULT_TRANSPORT": "http", # or "grpc" — see section 3
"DEFAULT_TTL": 60, # cache duration in seconds
}
This is the only place in the project where service_auth's URL
appears. The application code (below) never knows it.
Declaring the field
# orders/models.py
from django_event_bus.remote import RemoteForeignKey
class Order(models.Model):
user_id = RemoteForeignKey(
service="service_auth", # key in SERVICE_REGISTRY
resource="users", # must match the exposed `resource=` (section 3)
invalidate_on=["auth.user_updated"], # see "Invalidation" below
)
order.user.email # -> Django cache, otherwise HTTP/gRPC to service_auth
user_idis a plain integer column:makemigrations/migratework normally, just like anyIntegerField.order.user— the name is derived fromuser_idby dropping the_idsuffix, exactly like a DjangoForeignKeywould. If your field doesn't end in_id, the accessor becomes{field_name}_remote(or passaccessor_name=...explicitly).- Accessing
order.userresolves lazily: first the Django cache (REMOTE_DATA["CACHE_ALIAS"],locmemby default, a real Redis cache in production if you configureCACHES), then, if missing or expired, the configured transport. - Resource missing on the source service (404) →
order.userisNone, like an unresolved nullableForeignKey. Service unreachable (network failure, timeout) → raisesRemoteServiceUnavailableError: this is deliberate, unavailable data must be visible to your code, not hidden behind an ambiguousNone.
Classic trap: the cache must be shared across processes. Without an explicit
CACHES, Django useslocmem— an in-process cache. A service almost always runs as several processes (the web server,eventbus_worker,remote_grpc_server, an occasionalmanage.py shell): withlocmem, each has its own isolated memory, and the invalidation done byeventbus_worker(see "Event-driven invalidation" below) has no visible effect on the cache seen by the web server — silently, with no error, just data that never refreshes. Use a genuinely shared cache, for example Redis (Django ≥ 4.0 ships a native backend):CACHES = { "default": { "BACKEND": "django.core.cache.backends.redis.RedisCache", "LOCATION": "redis://localhost:6379/1", } }This is exactly what
example/does (see below) — this trap was discovered and fixed there by verifying the demo with persistent processes rather than amanage.py shellrestarted at every step (which hides the problem: a fresh process has a cold cache anyway).
3. Exposing your data (@expose_resource)
The problem, concretely
The previous section assumes service_auth knows how to answer "here's
user number 5" over HTTP and gRPC. Without a dedicated mechanism, you'd
have to hand-write a Django view and a gRPC resolver function,
duplicating the same field list in both — painful as soon as a service
exposes more than one resource, and a quick source of inconsistencies
(HTTP returns a field gRPC forgot, etc.). @expose_resource solves this
with a single declaration that feeds both transports.
Declaring a resource
# accounts/resources.py — auto-discovered, like events.py
from django.contrib.auth.models import User
from django_event_bus.remote import ResourceSerializer, expose_resource
@expose_resource
class UserResourceSerializer(ResourceSerializer):
class Meta:
model = User
resource = "users" # the key expected by RemoteForeignKey(resource=...)
fields = ["id", "username", "email", "full_name"]
def get_full_name(self, instance):
"""Computed field: doesn't exist on the model, built on demand.
The get_<field> convention, identical to Django REST Framework's
SerializerMethodField — familiar if you've used DRF before.
"""
full_name = f"{instance.first_name} {instance.last_name}".strip()
return full_name or instance.username
That's it: this class now answers over HTTP and gRPC, for any service
that has service_auth in its SERVICE_REGISTRY.
Customizing ResourceSerializer
| What you want to do | How |
|---|---|
| Expose every field of the model | fields = "__all__" (default if fields is omitted) |
| Expose a specific list | fields = ["id", "email"] |
| Expose everything except some fields | exclude = ["password"] (mutually exclusive with an explicit fields) |
| Add a computed field / rename a field | a get_<field>(self, instance) method — see get_full_name above |
Restrict the query (visibility, select_related, ...) |
override get_queryset(cls) (classmethod) |
| Full control over the output shape | override to_representation(self, instance) — bypasses fields/get_<field> |
@expose_resource
class UserResourceSerializer(ResourceSerializer):
class Meta:
model = User
resource = "users"
exclude = ["password", "is_superuser"]
@classmethod
def get_queryset(cls):
# Example: never expose deactivated accounts.
return User.objects.filter(is_active=True).select_related(...)
A resource already claimed by another class raises
ImproperlyConfiguredError (two serializers can't fight over the same
name); a missing Meta.model/Meta.resource raises the same exception,
early, rather than a confusing error on first call.
Wiring HTTP
# service_auth/urls.py
from django.urls import include, path
urlpatterns = [
path("api/", include("django_event_bus.remote.urls")),
]
One line, no matter how many resources are exposed: it serves
GET /api/<resource>/<pk>/ for all of them, with a clean 404 if the
resource or PK is unknown.
Wiring gRPC
No configuration needed: as soon as at least one resource is declared
via @expose_resource, REMOTE_DATA["GRPC_RESOLVER"] points by default
to the library's generic resolver. Just start the server:
python manage.py remote_grpc_server --port 50051
The gRPC contract is deliberately a single, generic one
(GetResource(resource, pk) -> JSON, see
django_event_bus/remote/proto/remote_resource.proto): no service needs
to write its own .proto. Data is carried as JSON rather than as
google.protobuf.Struct — Struct only has one numeric type (double)
and would silently turn every integer into a float (precision loss
beyond 2^53); JSON, on the other hand, distinguishes int from float,
just like the HTTP transport already does.
To switch a RemoteForeignKey to gRPC on the consumer side, without
changing anything on the provider side (same @expose_resource):
REMOTE_DATA = {
"SERVICE_REGISTRY": {"service_auth": {"grpc": {"target": "service-auth:50051"}}},
"DEFAULT_TRANSPORT": "grpc",
}
Securing the exposed endpoints
By default, neither resource_detail nor the gRPC server perform
authentication — see SECURITY.md and
THREAT_MODEL.md for the full picture (they assume a
trusted private network). Rate limiting is on by default; auth and TLS
are opt-in layers, usable independently:
# Provider side (service_auth)
REMOTE_DATA = {
"AUTH_TOKEN": "a-shared-secret", # HTTP: 401 / gRPC: UNAUTHENTICATED without it
"GRPC_SERVER_CREDENTIALS": "accounts.tls.build_server_credentials", # -> grpc.ServerCredentials
"REQUIRE_TLS": True, # fail closed instead of falling back to plaintext
# "RATE_LIMIT": {"LIMIT": 300, "WINDOW_SECONDS": 60}, # the default; None disables it
# "MAX_RESPONSE_BYTES": 2_000_000, # the default
}
# Consumer side (service_order)
REMOTE_DATA = {
"SERVICE_REGISTRY": {
"service_auth": {
"http": {"base_url": "https://...", "auth_token": "a-shared-secret"},
"grpc": {"target": "...", "auth_token": "a-shared-secret", "credentials": channel_creds},
},
},
}
AUTH_TOKEN is a shorthand for StaticTokenAuthBackend — one shared
secret for every caller. For a per-caller identity with a short,
self-expiring lifetime, use remote.auth.JWTAuthBackend instead (needs
pip install django-event-bus[jwt]) via REMOTE_DATA["AUTH_BACKEND"]
(dotted path to an instance):
# accounts/auth_backend.py
from django_event_bus.remote.auth import JWTAuthBackend
backend = JWTAuthBackend(settings.JWT_SIGNING_KEY, audience="service_auth")
# settings.py
REMOTE_DATA = {"AUTH_BACKEND": "accounts.auth_backend.backend"}
For rate limiting beyond the built-in default, wrap resource_detail
yourself in your own urls.py (e.g. with django-ratelimit) instead of
including django_event_bus.remote.urls as-is, and/or pass your own
grpc.ServerInterceptor via manage.py remote_grpc_server's
serve(..., interceptors=[...]) — it runs after the built-in rate
limiter and auth check.
Event-driven invalidation
user_id = RemoteForeignKey(
service="service_auth",
resource="users",
invalidate_on=["auth.user_updated"],
)
Reuses the event bus (part 1) instead of inventing a second mechanism:
when service_auth publishes any of the listed event_types (with the
PK in payload["id"] — the same convention already used by
RemoteSignal.send(payload={"id": ...}) for business events), the
matching resource's cache entry is deleted. Combined with the TTL
(DEFAULT_TTL), this covers two kinds of freshness: the TTL absorbs a
missed or delayed invalidation event (best effort), and the event, when
it arrives, invalidates immediately without waiting for the TTL to
expire. This assumes a worker (manage.py eventbus_worker) is running
on the consumer side — without an active worker, only the TTL applies.
Two-service demo: try it all for real
example/ contains service_auth and service_order, and data flows
in both directions — it's not just one service reading the other:
| exposes (provider) | consumes (RemoteForeignKey) |
events published | |
|---|---|---|---|
service_auth |
User (accounts/resources.py, HTTP+gRPC) |
OrderBookmark.order_id → service_order (accounts/models.py) |
auth.user_created/auth.user_updated |
service_order |
Order (orders/resources.py, HTTP) |
Order.user_id → service_auth (orders/models.py) |
orders.order_created/orders.order_updated |
Each service also has real web pages, no shell or curl required:
http://localhost:8001/accounts/ and http://localhost:8002/orders/
list existing accounts/orders and have a form to create one — each
creation redirects to a "dashboard" (.../<pk>/dashboard/) that shows
local data and the remotely resolved data side by side, with a form
to update the local value (triggering the event, hence invalidation on
the other service) and, on service_auth's side, a form to pin an
order by its id (accounts/views.py, orders/views.py).
Option A (recommended): everything in one command with Docker
docker compose -f example/docker-compose.yml up -d --build
A single image (Dockerfile at the repo root) is the base for seven
containers: Redis, a disposable _migrate container per service
(applies migrations then exits — the others wait for it to succeed to
avoid concurrent migrations on the same SQLite file), and each service's
_http/_grpc/_worker processes. Nothing to start by hand.
docker compose -f example/docker-compose.yml ps should show six
containers Up (the two _migrate ones exit, which is expected).
The simplest way: open http://localhost:8001/accounts/ and
http://localhost:8002/orders/ in a browser, create an account then an
order (referencing its id), and pin it from the account's dashboard —
everything happens through forms, no command to type.
Command-line alternative, via docker compose exec (a plain
manage.py shell, inside the container):
echo "
from django.contrib.auth.models import User
User.objects.create_user(username='bob', email='bob@example.com', password='x')
" | docker compose -f example/docker-compose.yml exec -T service_auth_http \
uv run python example/service_auth/manage.py shell
echo "
from orders.models import Order
Order.objects.create(reference='ORD-1', user_id=1)
" | docker compose -f example/docker-compose.yml exec -T service_order_http \
uv run python example/service_order/manage.py shell
echo "
from django.contrib.auth.models import User
from accounts.models import OrderBookmark
OrderBookmark.objects.create(user=User.objects.get(pk=1), order_id=1)
" | docker compose -f example/docker-compose.yml exec -T service_auth_http \
uv run python example/service_auth/manage.py shell
Then check both dashboards:
curl http://localhost:8002/orders/1/dashboard/ # service_order -> service_auth direction
curl http://localhost:8001/accounts/1/dashboard/ # service_auth -> service_order direction
Each shows its local data plus the remotely resolved one. Update either
side (same echo ... | docker compose exec ... manage.py shell pattern,
User.objects.get(pk=1).email = "..."; .save() or
Order.objects.get(pk=1).reference = "..."; .save()) then reload both
curl calls: each dashboard reflects the up-to-date value, invalidated
by the event published by the other service — without restarting any
container.
To stop and clean up everything: docker compose -f example/docker-compose.yml down -v.
Option B: by hand, step by step, to understand each piece
# Terminal 1: Redis
docker compose -f example/docker-compose.yml up -d redis
# Once, to create the sqlite databases
uv run python example/service_auth/manage.py migrate
uv run python example/service_order/manage.py migrate
# Terminal 2: service_auth answers over HTTP on :8001
uv run python example/service_auth/manage.py runserver 8001
# Terminal 3: service_auth also answers over gRPC on :50051
uv run python example/service_auth/manage.py remote_grpc_server --port 50051
# Terminal 4: service_auth consumes orders.order_updated
uv run python example/service_auth/manage.py eventbus_worker
# Terminal 5: service_order answers over HTTP on :8002
uv run python example/service_order/manage.py runserver 8002
# Terminal 6: service_order consumes auth.user_created/user_updated
uv run python example/service_order/manage.py eventbus_worker
Step A — an event crosses the bus. In a 7th terminal:
uv run python example/service_auth/manage.py shell
>>> from django.contrib.auth.models import User
>>> User.objects.create_user(username="bob", email="bob@example.com", password="x")
service_auth publishes auth.user_created; the worker in terminal 6
consumes it and persists a ReceivedEvent. service_order never
imported a single line of service_auth's code for this to work.
Step B — RemoteForeignKey resolves over HTTP, in both directions.
uv run python example/service_order/manage.py shell
>>> from orders.models import Order
>>> order = Order.objects.create(reference="ORD-1", user_id=1)
>>> order.user.email
'bob@example.com'
uv run python example/service_auth/manage.py shell
>>> from django.contrib.auth.models import User
>>> from accounts.models import OrderBookmark
>>> OrderBookmark.objects.create(user=User.objects.get(pk=1), order_id=1)
Check both dashboards in a browser (or curl):
http://localhost:8002/orders/1/dashboard/ and
http://localhost:8001/accounts/1/dashboard/.
Step C — event-driven invalidation, in both directions. Still in a
service_auth shell:
>>> u = User.objects.get(username="bob")
>>> u.email = "bob.new@example.com"
>>> u.save()
And in a service_order shell:
>>> order.reference = "ORD-1-v2"
>>> order.save()
Each .save() publishes an event (auth.user_updated /
orders.order_updated) that the other service's worker consumes to
invalidate its cache. Reload both dashboards (curl or browser,
without restarting any process): both now reflect the up-to-date values.
Step D — the same order.user works over gRPC. In the
service_order shell:
>>> from django.test import override_settings
>>> with override_settings(REMOTE_DATA={
... "SERVICE_REGISTRY": {"service_auth": {"grpc": {"target": "localhost:50051"}}},
... "DEFAULT_TRANSPORT": "grpc",
... }):
... print(order.user.as_dict())
{'id': 1, 'username': 'bob', 'email': 'bob.new@example.com', 'full_name': 'bob'}
Same @expose_resource on service_auth's side, different transport on
service_order's side — that's all that had to change.
Tests
uv run pytest # unit (LocMemBroker, FakeTransport, in-memory gRPC) — no infra
docker compose -f example/docker-compose.yml up -d redis
uv run pytest -m integration # against a real Redis
uv run ruff check . # PEP 8 / PEP 257 (pydocstyle) / imports / naming
uv run ruff format --check src tests example
uv run mypy src # PEP 484/526 (static typing)
Regenerating the gRPC stubs
After modifying src/django_event_bus/remote/proto/remote_resource.proto:
./scripts/generate_grpc_stubs.sh
Development
See CONTRIBUTING.md to contribute and CHANGELOG.md for the version history.
License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_event_bus-1.0.0rc3.tar.gz.
File metadata
- Download URL: django_event_bus-1.0.0rc3.tar.gz
- Upload date:
- Size: 161.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6ed3dd9adabb12d5a3f0c7dd645c422a778c2cd86f82e74df3e729198c971095
|
|
| MD5 |
810451c42046ade6c39ed891cfc51d1c
|
|
| BLAKE2b-256 |
f492a000edbbcd9453839f297d914f3529fd56893f61fa880ff0a3a7b8bfa841
|
Provenance
The following attestation bundles were made for django_event_bus-1.0.0rc3.tar.gz:
Publisher:
publish.yml on alzeph/django-event-bus
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_event_bus-1.0.0rc3.tar.gz -
Subject digest:
6ed3dd9adabb12d5a3f0c7dd645c422a778c2cd86f82e74df3e729198c971095 - Sigstore transparency entry: 2528563160
- Sigstore integration time:
-
Permalink:
alzeph/django-event-bus@e6946f5be877e999092aed5ae290b63f3f5ecf0b -
Branch / Tag:
refs/tags/v1.0.0rc3 - Owner: https://github.com/alzeph
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e6946f5be877e999092aed5ae290b63f3f5ecf0b -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_event_bus-1.0.0rc3-py3-none-any.whl.
File metadata
- Download URL: django_event_bus-1.0.0rc3-py3-none-any.whl
- Upload date:
- Size: 73.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
efe96503cce79128dd5f00455ca40562b983131e8d4bcac196b5a63f66ecddd1
|
|
| MD5 |
ece09aac5554777d0ae280ff0cc588dc
|
|
| BLAKE2b-256 |
ac28df8469d6aef524a5ce62b3ff908ca4b8c4ff208892086e6c6d041345f551
|
Provenance
The following attestation bundles were made for django_event_bus-1.0.0rc3-py3-none-any.whl:
Publisher:
publish.yml on alzeph/django-event-bus
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_event_bus-1.0.0rc3-py3-none-any.whl -
Subject digest:
efe96503cce79128dd5f00455ca40562b983131e8d4bcac196b5a63f66ecddd1 - Sigstore transparency entry: 2528563322
- Sigstore integration time:
-
Permalink:
alzeph/django-event-bus@e6946f5be877e999092aed5ae290b63f3f5ecf0b -
Branch / Tag:
refs/tags/v1.0.0rc3 - Owner: https://github.com/alzeph
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e6946f5be877e999092aed5ae290b63f3f5ecf0b -
Trigger Event:
release
-
Statement type: