Email Notifications in Workflow Systems: The Wrong Way vs The Right Way
The Problem Scenario
You’re building a Compliance Case Management system.
Each case moves through stages like:
Submission → Review → Approval → Closure
At these points, the system must send different emails:
- Submission confirmation
- Reviewer assignment
- Escalation alerts
- Closure summaries
But each case type (Incident, Audit, Exception Request…) has different templates, different recipients, and different business rules.
Sounds simple.
But this is exactly where teams slip into the wrong design pattern.
❌ THE WRONG PATTERN — Hardcoding Everything Into Power Automate
This is the most common mistake across Power Platform, Appian, Pega, Salesforce, and MuleSoft:
“Put all notification logic into a flow.”
Typically, this flow:
- Uses Switch (CaseType) logic
- Inside each branch checks If (Stage)
- Hardcodes subject lines and HTML bodies
- Hardcodes recipients (“To: compliance@company.com”)
- Hardcodes conditions (“If severity = high → send escalations”)
- Repeats similar logic for every case type
Trigger: “When a row is added or modified” (Dataverse, Case table). Filters on Stage changes (e.g., via a Condition: “If Stage is not equal to triggerOutputs()?[‘body/previousStage’]”). Outputs: Full Case record. Initial Data Fetch:
- “Get row” for Case details (CaseID, CaseType, Stage, Severity, SubmitterEmail).
- Parallel branches: “Get rows” for related Users (e.g., Reviewer, Manager) via OData filters like SubmitterID eq ‘@{triggerOutputs()?[‘body/SubmitterID’]}’.
Core Decision Tree (The Spaghetti Core):
- Switch on CaseType (e.g., Switch on @{triggerOutputs()?[‘body/CaseType’]} with cases: ‘Incident’, ‘Audit’, ‘Request for Exemption’, Default).
- This is the top-level router, but each arm duplicates nearly identical logic—violating DRY (Don’t Repeat Yourself).
- Nested If on Stage (Inside each Switch branch): Condition like “If Stage equals ‘Review’” → Yes: Proceed to notification block; No: Branch to next If (e.g., ‘Approval’, ‘Closure’). This creates a pyramid of 3-5 nested Conditions per type, with fall-throughs for unhandled stages.
- Hardcoded Subjects and Bodies (In each Stage If/Yes):
- Compose action for Subject: e.g., For Incident/Review: “Incident Review Assigned: Case ##{CaseID} – Action Required”.
- Compose for Body: Raw HTML string like “<html><body>Dear Reviewer,<p>Case {CaseID} ({CaseType}) is in {Stage} stage.</p><p>Severity: {Severity}</p><a href='{PortalLink}’>View Case</a></body></html>”. Placeholders replaced via expressions (e.g., replace(variables(‘bodyTemplate’), ‘{CaseID}’, string(triggerOutputs()?[‘body/CaseID’]))). No templating engine—just manual string swaps, prone to escaping errors (e.g., unescaped quotes breaking HTML).
- Hardcoded Recipients:
- To: Static like “compliance@company.com” or dynamic-but-rigid like @{triggerOutputs()?[‘body/ReviewerEmail’]} (assumes it’s always populated; fails if null).
- CC/BCC: Often overlooked, hardcoded as arrays (e.g., [‘manager@company.com’, ‘stakeholders@company.com’]) without role-based resolution.
- Hardcoded Conditions for Variations (Nested deeper):
- Another Condition: “If Severity equals ‘High’” → Yes: Add escalation block (e.g., Delay 24 hours, then SendEmail to Manager with subject “URGENT: Escalation for {CaseID}”).
- For Closure stage: If CaseType=’Audit’, attach a hardcoded PDF summary via “Get file content” from a fixed SharePoint path.
- Send Actions: Outlook.SendEmailV2 with the composed Subject/Body/To. No error handling beyond basic “Scope” try-catch, which logs to a generic SharePoint list.
Repetition Across Case Types:
- For ‘Incident’: Switch arm repeats the full If(Stage)/Hardcode/Condition(Severity)/Send pyramid, but tweaks subjects (e.g., “Incident Alert” vs. “Audit Query”).
- For ‘Audit’: Identical structure, but bodies include compliance-specific legalese (e.g., “Per SOX 404…”).
- For ‘Request for Exemption’: Yet another copy-paste variant, with unique recipients like legal@company.com.
- Default arm: A catch-all that sends a generic “Unknown Type” email—hiding bugs until prod.
Post-Send Cleanup: Variable updates (e.g., set LastNotifiedDate), maybe a “Update row” on Case. Ends with a “Terminate” on success.
This results in:
- One giant monolithic flow OR
- Multiple similar flows duplicated per case type
Why this is bad
| Problem | Result |
|---|---|
| Hardcoded templates | Any text change requires a developer |
| Hardcoded recipients | Re-org breaks flows |
| Hardcoded branching | Every new case type/stage requires changes |
| Duplication | Logic drifts across copies |
| Difficult to test | Increasingly fragile and unreadable |
| Business can’t maintain | All ownership shifts to IT |
| Flow becomes huge | Loading and editing becomes painful |
Ultimately, the automation becomes a tangled mess of conditions, instead of a reusable notification engine.
✅ THE RIGHT PATTERN — A Metadata-Driven Notification Engine
Instead of burying business rules inside flows, we move all “decision-making” into data (Dataverse tables).
The flow becomes small, simple, stable, and future-proof.
The core idea:
Flows should only orchestrate.
Data should decide.
Let’s design this properly.
⭐ 1. EmailTemplate Table (WHERE TEMPLATE CODE IS FOUND)
This is where all templates live.
EmailTemplate
| Column | Description |
|---|---|
| EmailTemplateId (PK) | Unique identifier |
| TemplateCode | Code used by rules (e.g., INCIDENT_SUBMITTED) |
| Subject | Text with tokens (e.g., “Case @CaseId Submitted”) |
| Body | HTML with tokens |
| Language | EN / FR / DE (optional) |
| Active | Yes/No |
TemplateCode is the key column the flow will use to fetch the right template.
⭐ 2. NotificationRule Table (THE BRAIN OF THE ENGINE)
This table links Case Type + Stage → TemplateCode + RecipientType.
NotificationRule
| Column | Description |
|---|---|
| NotificationRuleId (PK) | |
| CaseType | Incident / Audit / Exception |
| Stage | Submitted / Approved / Closed |
| TemplateCode | (Lookup/Relation → EmailTemplate.TemplateCode) |
| RecipientType | Submitter / Reviewer / Manager |
| Active | Yes/No |
This table is the bridge between workflow events and email templates.
👉 This is where the flow finds TemplateCode.
👉 And TemplateCode maps to EmailTemplate.
⭐ 3. RecipientMatrix Table
Maps RecipientType to actual email addresses.
RecipientMatrix
| RecipientType | Source |
|---|---|
| Submitter | Case.SubmitterEmail |
| Reviewer | Case.ReviewerUserId |
| Manager | Case.Manager.ManagerEmail |
| EscalationList | “compliance-escalations@company.com“ |
No recipient is ever hardcoded in flows.
⭐ 4. TokenMap Table (Dynamic Values)
Defines which placeholders the engine should replace.
TokenMap
| Token | Entity | Field |
|---|---|---|
| @CaseId | Case | CaseNumber |
| @UserName | User | FullName |
| @DueDate | Case | DueDate |
These tokens will be replaced inside subject and body.
⭐ Putting It All Together: How the Flow Works
Here is the entire logic of the unified email notification flow:
Step 1: Trigger
When Case status/stage changes.
Step 2: Read CaseType and Stage
CaseType = Case.CaseType
Stage = Case.Stage
Step 3: Lookup NotificationRule
Query NotificationRule where:
CaseType = X
AND Stage = Y
AND Active = true
The rule returns:
- TemplateCode
- RecipientType
Step 4: Lookup EmailTemplate based on TemplateCode
Query EmailTemplate where:
TemplateCode = rule.TemplateCode
Retrieve:
- Subject
- Body
Step 5: Resolve Recipients
Query RecipientMatrix where:
RecipientType = rule.RecipientType
This tells the flow where to fetch emails from.
Step 6: Replace Tokens
Use TokenMap to replace:
@CaseId, @UserName, @DueDate…
Step 7: Send Email
Simple — one “Send Email” action.
Step 8: Log Email (optional)
Save to EmailLog table.
🧩 Why This Model Works
| Feature | Bad Pattern | Metadata Model |
|---|---|---|
| Templates | Hardcoded in flow | Stored in EmailTemplate |
| Recipients | Hardcoded | Stored in RecipientMatrix |
| Rules | Hardcoded branching | Stored in NotificationRule |
| Token substitution | Manual replace() | TokenMap table |
| Number of flows | Many | One |
| Scalability | Low | High |
| Business ownership | None | Full (data-driven) |
| Maintenance | Expensive | Easy |
🏁 Conclusion
The biggest mistake teams make is not the flow itself —
it’s the data model driving the flow.
When business rules live in code (flows), the system becomes rigid.
When business rules live in data (tables), the system becomes adaptable.
A well-designed metadata-driven notification engine:
- Supports unlimited case types
- Supports unlimited templates
- Allows business users to change content
- Removes branching logic from flows
- Cleanly separates automation and business policy
- Scales effortlessly
This is the architectural pattern every workflow-driven system should follow.
Leave a Reply