Platform — Core concepts
These four concepts are the key to understanding all of Platform. Once you know them, the following pages become much lighter.
EntityEvent — "something just changed"
EntityEvent is a small message saying an entity just changed. Examples: "Account #123 was created", "Employee #45 was updated".
Each event carries:
- EntityType — entity type (Account, Attendee, Invoice, Employee, etc.), each with a numeric id.
- EventType / ActionType — what action happened (
Create_Account,Update_Employee, etc.), also numeric id. - Entity identity — id/guid to know exactly which entity.
- (Optional) Payload / snapshot — data snapshot for processing effects.
Service only needs to publish the event to entity-event-queue; it does not send email or write history itself. A worker reads the event and handles the effects. This is the spirit of event-driven: separate "what happened" from "what follows from it".
Side effect & Post-processor — independent effects
From one event, multiple independent side effects may be produced:
Each post-processor owns one concern (notification, history, etc.) and runs independently: one failing does not break the other. Each post-processor walks a mapping registry — a set of small mappings, each answering: "does this event produce my effect, and if yes, what does it look like?". Mappings do not save to DB themselves; the post-processor owns persistence + deduplication consistently.
Hybrid — two ways to create a notification
This is the most important architecture decision. There are two paths to create a NotificationJob:
| Path | Who creates the job | Use when |
|---|---|---|
| Direct (action-derived) | Service creates through NotificationJobFactory | Needs context computed at call time — before/after diff, request comment, dynamic recipients |
| Event-driven (event-derived) | Service only publishes event; worker creates job through mapping | Notification is a pure reaction to changed state; recipients/content can be derived from entity + settings |
Both go through the same NotificationJobFactory and the same send pipeline. The only difference is who presses the create-job button.
Why not force everything to one side?
- Do not force everything event-driven because:
- Call-time context is lost: worker only sees state after commit and cannot reconstruct before/after diff or request input.
- Over-firing ⚠️: many current events are generic CRUD (
Create_Account, etc.) fired from many places for audit. Mapping them directly to notification would send much more broadly than today → user spam.
- Do not keep everything direct because: duplicated contract in ~49 places (easy to get wrong), not idempotent (each place uses its own
Guid.NewGuid()), service is coupled to notification details.
➡️ Hybrid takes the strengths of both: event-derived gets decoupling + idempotency; action-derived keeps context. The safe way to "opt" a generic CRUD event into notification is to attach a hint (NotificationEventTypeId) at the exact guarded call-site — see Common cases.
Idempotency — deduplicate with deterministic keys
Azure Queue is at-least-once: the same event may be delivered (and processed) multiple times. If each processing creates a new row with a random key (Guid.NewGuid()), we get duplicates — email sent twice, history has two rows.
Solution: deterministic key — key derived from event identity, not random.
- Notification event-derived: uses
NotificationEventKey.ForEntityEvent(...)→ same event produces same key ⇒ retry does not duplicate. - History: uses
HistoryEventKey.ForEntityEvent(...)asRowKey+ insert-or-replace ⇒ retry overwrites exactly one row, genuinely different events get different keys (history preserved). - Action-derived (direct): intentionally keeps
Guid.NewGuid()(at-least-once), because these flows have no stable replay identity; recipients/content are computed at call-time.
Golden rule: one notification type may be produced by exactly one path (event-derived or direct, not both) — otherwise it sends twice. A dual-path guardrail test enforces this.
Next: see how these concepts assemble in Architecture.
EntityEvent payload and BlobRef
EntityEvent is serialized as one complete message before the queue limit is checked. If the message is above about 45 KiB, a non-empty payload is stored in Blob and the queue keeps a reference for the worker to read. An event that requires payload/Blob data but cannot read it fails closed; it does not invent an empty payload.
Real-world example: A large history snapshot is not truncated in the queue; the worker resolves
BlobRefand then runs history/notification mapping.
SourceKey traceability
SourceKey is a business-source key connecting NotificationJob and OutgoingMessage to an invoice, account or entity. It is not the deterministic idempotency key; it exists for message-level history and coordination.
Real-world example: Two recipients of one invoice produce two
OutgoingMessagerows with the sameSourceKey, allowing support to inspect the complete invoice delivery result.