Notifications accumulate. Someone ships order confirmations to buyers. Someone else adds a copy for the sales rep. A customer asks to be told about partial shipments, so that goes in too. None of these decisions are wrong on their own, and the result is still a mess: messaging logic scattered across every module that happens to change state.
The symptom that forced the rewrite wasn't technical. Operations wanted to stop sending one particular message to one particular role, and the answer was "that needs a release". A one-line policy change, gated behind a deploy. That's the point at which configuration has leaked into code.
Separating the event from the audience
The core move is unglamorous: split something happened from who should hear about it.
Business modules stop knowing about recipients entirely. They emit an event — order placed, shipment dispatched, challan generated — with the context needed to describe it. Everything after that is the notification layer's problem.
That layer owns an event registry: a declaration of every event the system can emit, which module owns it, and which recipient types are enabled for it by default along with the template each receives. The registry is the specification. New events are added there rather than discovered by grepping for send calls.
Recipient types, and the one that resolves to nobody
A recipient type is not a person. It is a role relative to the event — buyer, seller, employee, field rep, or an explicit custom list. Each type has a resolver that turns the event's context into actual contact details: given this order, find the buyer's number.
The interesting case is the one that resolves to nothing at all.
Some events already know their audience. A shift reminder is addressed to one specific employee, resolved by the thing that produced it; there is no lookup for the notification layer to perform. Early on I handled these by bypassing the rules engine, which immediately meant two code paths and a category of notification that couldn't be configured like the others.
The fix was a recipient type that is deliberately registered with no resolver. It means "the person this message is already addressed to". The payload carries its own destination, the resolver returns nothing, and delivery proceeds to whatever the producer embedded. The rule row still exists, so operations can still enable or disable that event per tenant — which was the entire point. One code path, one configuration surface, one less exception to remember.
This is the null-object pattern, and it is worth more in an event system than it looks on paper. Every special case you avoid is a branch that cannot drift.
The bug: one person, two roles
After the engine went live, a specific complaint arrived: some people were getting the same order notification twice.
Not everyone, and not every order. It took a while to see the shape, because the obvious suspects were all innocent — the queue wasn't redelivering, the job wasn't running twice, and the rules table had exactly one row per event per recipient type.
The actual cause was in resolution. For certain orders, the context carried both a buyer-side contact and a sales-rep contact. Both recipient types were enabled. Both resolvers ran, as designed. And for some stores the same human being occupies both roles — a distributor who is also the rep on their own accounts.
Two correct rules, two correct resolvers, one phone number, two messages.
The immediate fix was to stop attaching the rep's details to events where the buyer was already a recipient — narrow, targeted, shipped quickly. The right fix, and the one I'd start with now, is a deduplication step between resolution and dispatch: resolve every recipient type, collapse the result set by destination, and send once per unique destination with the highest-priority template that applied.
That generalises. It fixes the two-role case, and it fixes every future case where two rules happen to land on the same person — without anyone having to anticipate which pairs those are.
Validate rules on write, not on send
One more thing worth doing early. When rules are user-editable, they will eventually contain nonsense — a recipient type that doesn't exist, an event key with a typo, a custom recipient list with no contacts in it.
Validating at send time is too late. The event has already happened, you're inside a worker, and your options are to drop the notification or crash the job. Both are bad and neither is visible to the person who made the mistake.
Validating at write time means the API rejects the bad rule immediately, with a message pointing at the field. The person who caused the problem is still looking at the screen. Same check, radically different cost.
Was it worth it
The engine is more code than the if statements it replaced, and for a
smaller system it would be over-engineering. The thing that justified it was tenancy:
once different stores legitimately want different notification behaviour, encoding
that in branches means the branches multiply with the tenants.
The measurable outcome is that routine changes stopped being deploys. Enabling an event for a role, changing a template, silencing a message for one store — all of it moved from engineering to operations, which is where it belonged.
The lesson I took is more general than notifications. When you find yourself shipping code to change a policy, the policy wants to be data. The hard part is never the rules table. It's being honest about the fact that your domain model — roles, in this case — is a simplification, and that reality will eventually put two of them on the same phone number.