Platform — System architecture
This page assembles the concepts into a technical picture: which components exist and how data flows.
Big picture
Key point: every notification creation path converges on NotificationJobFactory, then enters the same send pipeline. Notification, History, and Domain handler are three independent branches — one branch failing must not skip another branch in the same processing pass.
① Entity Event Flow
Service publishes events by calling SendEntityEventAsync(...). Event enters entity-event-queue. EventHandler Worker reads the queue and routes by EventTypeId:
- Calls the corresponding domain handler (Account/Attendee/Invoice, etc.) for core business work (financial projection, scheduler creation, etc.).
- Runs registered post-processors (notification, history) — each walks its own registry.
Append-only routing: adding a new concern = add a new post-processor with its own registry, not bloating handler routing or branching inside an existing post-processor.
② Notification Platform
Two job creation paths → one send pipeline
NotificationJob (Azure Table) + NotificationScheduler (SQL, StatusId = Ready) are only work-to-do records. Then, independently, the 3-step pipeline runs:
| Step | Worker | What it does |
|---|---|---|
| 1 | Tux.Workers.Notification.Scheduler | Scans scheduler Ready; checks BU Notification Settings (is this type enabled, who receives it). |
| 2 | Tux.Workers.Notification.Generator | Reads (string)job.EventData, resolves recipients, renders template; creates OutgoingMessage (and UserMessage if needed). |
| 3 | Tux.Workers.Notification.Distributor | Sends for real through provider; "claim before send" to avoid duplicate delivery. |
Because the send pipeline was already async, moving a flow to event-driven does not make delivery faster — it only moves where the job is created into the worker. The benefit is architecture (decoupling, idempotency), not speed.
Important contracts (invariants)
| Code | Content |
|---|---|
INV-NOTIF-PLATFORM-001 | Every job is created through factory; EventData is always string (Generator reads with (string)job.EventData). |
INV-NOTIF-PLATFORM-002 | Job created by worker uses deterministic EventKey + query-before-append ⇒ retry does not duplicate. |
INV-NOTIF-PLATFORM-003 | Explicit mapping through registry; overloaded event id without context must fail closed (log + skip). |
INV-NOTIF-PLATFORM-004 | One notification type is produced by exactly one path (dual-path guardrail). |
INV-NOTIF-PLATFORM-005 | Worker trigger only appends job/scheduler; does not mutate source aggregate, does not render/send directly. |
INV-NOTIF-PLATFORM-006 v2 | "1 event-derived notification job = 1 row" (deterministic sentinel RowKey + point-read dedup). |
③ Entity History
History follows the same registry model as notification, but stores to EntityHistory (Table + Blob snapshot):
Three history mapping types:
| Type | Interface | When |
|---|---|---|
| Single-record | IEntityEventHistoryMapping | 1 event → 1 row (Account, Attendee, Employee, Invoice, CreditNote, Payment, Contact). |
| Multi-record | IEntityEventHistoryMultiMapping | 1 event → N rows for many entities (PaymentBatch + each Overpayment), each row keyed by its own identity. |
| Hinted | HintedHistoryMapping | Generic CRUD event carries snapshot + derived action — no separate class needed. |
Mapping must not: assign RowKey, dedup by itself, create scheduler/fan-out, or mutate source aggregate. It only reads state and returns records; post-processor owns idempotency + persistence.
History invariants
| Code | Content |
|---|---|
INV-HIST-PLATFORM-001 | Every event-derived history row is produced by registered mapping; post-processor does not change by type. |
INV-HIST-PLATFORM-002 | History row created by worker uses deterministic RowKey + upsert ⇒ retry overwrites, no duplicate. |
INV-HIST-PLATFORM-003 | History post-processor only creates history; no scheduler/fan-out. |
INV-HIST-PLATFORM-004 | Mapping only reads state; does not mutate source aggregate. |
INV-HIST-PLATFORM-005 | History runs independently from notification; one concern failing does not skip another. |
📚 The invariant table here is a summary to understand behavior, not the full specification.
Scope boundaries (intentionally not merged)
- Booking stays in its own
BookingHistorytable (different shape/write path). - Audit-only events stay in
EntityEventLog, with no history mapping. EntityEventLog(raw audit) andEntityHistory(history projection) remain two separate mechanisms.
Storage summary
| Artifact | Store | Role |
|---|---|---|
entity-event-queue | Azure Queue | Transports EntityEvent to worker |
NotificationJob | Azure Table | One notification event; partitioned by entity to open history |
NotificationScheduler | SQL | Queue row: "there is notification to process" |
EntityHistory | Azure Table + Blob | Entity change history (timeline) |
OutgoingMessage | Azure Table | One row/recipient/channel; delivery state |
EntityEventLog | (audit store) | Raw event audit |
Next: map these components to concrete files & classes in Codebase flow.