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

ProblemResult
Hardcoded templatesAny text change requires a developer
Hardcoded recipientsRe-org breaks flows
Hardcoded branchingEvery new case type/stage requires changes
DuplicationLogic drifts across copies
Difficult to testIncreasingly fragile and unreadable
Business can’t maintainAll ownership shifts to IT
Flow becomes hugeLoading 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

ColumnDescription
EmailTemplateId (PK)Unique identifier
TemplateCodeCode used by rules (e.g., INCIDENT_SUBMITTED)
SubjectText with tokens (e.g., “Case @CaseId Submitted”)
BodyHTML with tokens
LanguageEN / FR / DE (optional)
ActiveYes/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

ColumnDescription
NotificationRuleId (PK)
CaseTypeIncident / Audit / Exception
StageSubmitted / Approved / Closed
TemplateCode(Lookup/Relation → EmailTemplate.TemplateCode)
RecipientTypeSubmitter / Reviewer / Manager
ActiveYes/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

RecipientTypeSource
SubmitterCase.SubmitterEmail
ReviewerCase.ReviewerUserId
ManagerCase.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

TokenEntityField
@CaseIdCaseCaseNumber
@UserNameUserFullName
@DueDateCaseDueDate

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

FeatureBad PatternMetadata Model
TemplatesHardcoded in flowStored in EmailTemplate
RecipientsHardcodedStored in RecipientMatrix
RulesHardcoded branchingStored in NotificationRule
Token substitutionManual replace()TokenMap table
Number of flowsManyOne
ScalabilityLowHigh
Business ownershipNoneFull (data-driven)
MaintenanceExpensiveEasy

🏁 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.

Posted in

Leave a Reply

Discover more from Power Platform & D365 Trick or Treats

Subscribe now to keep reading and get access to the full archive.

Continue reading