Producers, consumers — and why a topic is not a queue
coreadvancedThe section draws one shape: Django emits a domain event, a broker carries it, and several consumers — billing, notification, analytics, search — react independently. The producer names *what happened* (`order.placed`) rather than what should be done, and does not know who is listening. The distinction that matters most is queue versus topic. A **queue** delivers each message to exactly one consumer, which is how you distribute work. A **topic** delivers each message to every subscriber, which is how you fan out to four systems that each need it.
Think of it as
The change event-driven design asks for is in what a message *says*. A task says "send the confirmation email"; an event says "order 88 was placed". The first names an action, so the producer has to know every action that should follow, and adding a fifth consumer means editing the producer. The second names a fact that is already true, so consumers can be added and removed without the producer changing at all — which is the entire benefit, and the reason to write events in the past tense. It also changes who owns the decision: if billing decides to stop charging on placement, that is billing's change, made in billing. Getting queue and topic straight is the other half, and confusing them produces two opposite failures. Use a queue where you meant a topic and only one of your four consumers sees each event — the other three silently never run, and because each individual message *was* processed, nothing looks broken. Use a topic where you meant a queue and every worker in a pool processes the same job, so the email goes out four times. The rule to hold on to is that fan-out is a property of the *destination*: one topic feeds several subscriptions, and each subscription is a queue serving one pool of workers that share the work. That shape gives you both at once, which is why every mature broker arranges it that way. It is also worth being clear about what Django signals are not. They are synchronous and in-process — the handler runs inside the same transaction and the same request, so a slow one slows the request and a failing one can break the save. They are a good decoupling tool inside a Django project and they are not an event bus; treating them as one gives you all of the coupling problems you were trying to remove plus a new failure mode. The last decision is what goes in the payload, and the useful default is the identifier plus the few fields a consumer needs to decide whether it cares. A fat payload duplicating the whole object becomes a second, stale copy of your data; a payload of only an id forces every consumer to call back and turns one event into four synchronous requests to the service that emitted it.
What we're doing: Emit a well-shaped domain event from one place, with an envelope consumers can route and deduplicate on.
- 7–9
- The event id is generated once, when the event is created. Generating it at publish time would give a republished event a new id, defeating every consumer's deduplication.
- 11
- `aggregate_id` is what a partitioning broker keys on, so all events for one order land in one partition and keep their relative order. Without it, ordering is arbitrary across the whole topic.
- 22–24
- Past tense, and no imperative. `order.placed` lets a fifth consumer appear next year with no change here; `send_confirmation_email` would put that decision back in the producer.
- 29–35
- The middle ground on payload size. Enough for a consumer to filter without calling back, not so much that the event becomes a stale duplicate of the order.
- 34
- `str(order.total)` because JSON has no decimal type — serialising a `Decimal` as a float reintroduces the rounding error the model deliberately avoided.
Why this works: Every consumer receives the same envelope with a stable id and an ordering key, can decide for itself whether an event is relevant, and can be added without the producer changing.
Publishing a command instead of an event
Wrong
Better
What you see: Every new downstream feature requires a change to the order service, and its code gradually accumulates knowledge of billing, search, analytics and marketing.
Why: A command names an action, so the producer must know which actions should follow — which is the coupling an event bus exists to remove, now expressed through a broker instead of a function call. It also puts each consumer's business rules in the wrong place: "we only charge for orders over £5" belongs in billing, but a `charge_customer` event forces the order service to know it. Publishing the fact inverts the dependency: consumers subscribe to what happened and own their own decisions, and the producer never learns their names.
- Django emits order.placed
- Broker — one topic, four subscriptions — each subscription is itself a queue, shared by that consumer's worker pool
- billing.order-placed — 3 workers share this queue
- notification.order-placed — 2 workers share this queue
- analytics.order-placed — 1 worker
- search.order-placed — 2 workers share this queue
- Consumers — independent, and each may fail alone — adding a fifth changes nothing in Django
- Billing — raises the invoice
- Notification — sends the confirmation
- Analytics — appends to the warehouse
- Search — reindexes the order
Queue or topic — and what going wrong looks like
Together
Task, signal or event — three things that are easy to conflate
Together
Remember: Publish facts in the past tense (`order.placed`), not commands — a command puts every consumer's decision back in the producer. A queue delivers each message to one consumer (work distribution); a topic delivers to every subscriber (fan-out), and the mature shape is one topic feeding several subscriptions that are each a queue for one worker pool. Choosing wrongly in the fan-out direction is the dangerous one, because every message *is* processed and nothing looks broken while three of four systems never run. Django signals are synchronous and in-process — decoupling within a project, never an event bus.
See also: ordering retries and idempotent consumers · eventual consistency outbox and versioning · side effects and when to avoid signals

