Update notification setup¶
Perfect. This is a complete, production-grade design for a template variable validation + rendering system, wired cleanly into Clean Architecture + BullMQ.
This is the setup Iβd actually ship.
π― Goal¶
You want to:
- Define notification templates with {{ variables }}
- Ensure correct variables are provided
- Catch errors before enqueueing
- Render safely at send-time
- Support retries, audits, and versioning
π§± Core Architecture (high level)¶
Domain Event
β
CreateNotificationUseCase
ββ Validate variables against template schema
ββ Persist notification (templateId + variables)
β
Queue job (notification.send)
β
Worker
ββ Load template
ββ Render with variables
ββ Send
1οΈβ£ Template definition (with variable schema)¶
NotificationTemplate table¶
NotificationTemplate {
id: string // invoice_due
version: number // 2
channel: 'email' | 'push'
subject: string
body: string // "Hi {{name}}..."
variableSchema: JSON // validation contract
isActive: boolean
}
Example record¶
{
"id": "invoice_due",
"version": 2,
"channel": "email",
"subject": "Invoice due reminder",
"body": "Hi {{ name }}, your invoice {{ invoiceNumber }} is due on {{ dueDate }}.",
"variableSchema": {
"type": "object",
"required": ["name", "invoiceNumber", "dueDate"],
"properties": {
"name": { "type": "string" },
"invoiceNumber": { "type": "string" },
"dueDate": { "type": "string", "format": "date" }
}
}
}
π Key idea
Templates declare what variables they require, not how to get them.
2οΈβ£ UserNotification (store variables, not rendered text)¶
UserNotification {
id: string
userId: string
templateId: string
templateVersion: number
variables: JSONB
status: 'pending' | 'sent' | 'failed'
error?: string
createdAt
}
Example:
3οΈβ£ Variable validation (before enqueueing) β ¶
Use JSON Schema + Ajv¶
import Ajv from 'ajv';
const ajv = new Ajv({ allErrors: true });
export function validateVariables(
schema: object,
variables: Record<string, unknown>
) {
const validate = ajv.compile(schema);
if (!validate(variables)) {
throw new Error(
`Invalid notification variables: ${ajv.errorsText(validate.errors)}`
);
}
}
4οΈβ£ CreateNotificationUseCase (clean & safe)¶
export class CreateNotificationUseCase {
constructor(
private readonly templateRepo: TemplateRepository,
private readonly notificationRepo: NotificationRepository,
private readonly jobQueue: JobQueue
) {}
async execute(input: {
userId: string;
templateId: string;
variables: Record<string, unknown>;
}) {
const template = await this.templateRepo.getActive(input.templateId);
validateVariables(template.variableSchema, input.variables);
const notification = await this.notificationRepo.create({
userId: input.userId,
templateId: template.id,
templateVersion: template.version,
variables: input.variables,
status: 'pending',
});
await this.jobQueue.enqueue('notification.send', {
notificationId: notification.id,
});
return notification.id;
}
}
π Why this is important
- Broken notifications never enter the queue
- Workers donβt need to guess
- Errors fail fast, near the source
5οΈβ£ Rendering (ONLY in the worker)¶
Use Handlebars (strict mode)¶
import Handlebars from 'handlebars';
export function renderTemplate(
template: string,
variables: Record<string, unknown>
): string {
const compiled = Handlebars.compile(template, {
strict: true, // π₯ throws if variable missing
noEscape: true,
});
return compiled(variables);
}
6οΈβ£ BullMQ worker (email example)¶
new Worker('notification-queue', async job => {
const { notificationId } = job.data;
const notification = await notificationRepo.get(notificationId);
const template = await templateRepo.getVersion(
notification.templateId,
notification.templateVersion
);
try {
const body = renderTemplate(template.body, notification.variables);
await emailService.send({
to: notification.userId,
subject: template.subject,
body
});
await notificationRepo.markSent(notificationId);
} catch (err) {
await notificationRepo.markFailed(notificationId, err.message);
throw err; // allow retry
}
});
7οΈβ£ Why this design is¶
best practice¶
β Validation happens¶
before enqueue¶
No poison jobs.
β Rendering happens¶
at send-time¶
Supports:
- retries
- template updates
- auditing
β Variables are persisted¶
You can:
- resend notifications
- inspect what user saw
- debug production issues
β Templates are versioned¶
Old notifications render with old templates.
π« What this avoids (very important)¶
β String replace hacks
β Rendering in controllers
β Losing variable context
β Silent failures
β Broken retries
π§ Golden rules (remember these)¶
- Templates define placeholders
- Events provide variables
- Validate before queue
- Render only in workers
- Persist variables always
π Optional next steps (I highly recommend)¶
I can next:
- add template preview tooling
- add i18n support
- build a variable auto-resolver
- add per-channel fallback templates
- design a notification retry UI
Just tell me which one you want next.
Add action button to notifications¶
Great question β action buttons are not a UI concern only; they must be modeled correctly in backend + template design, otherwise they become brittle.
Iβll give you the best-practice, production-grade approach, and then map it directly to your existing notification system (templates + variables + clean architecture).
π§ First principle (anchor this)¶
Notifications describe actions.
Clients decide how to render them.
So:
- Backend defines what actions are possible
- Frontend/mobile decides how buttons look
π― What you want to achieve¶
A notification like:
βYour invoice is dueβ
With buttons:
- Pay Now
- View Invoice
β Correct Architecture (high-level)¶
NotificationTemplate
ββ content (body, title)
ββ actions[] β declarative
UserNotification
ββ variables
ββ resolvedActions[] (optional)
Client
ββ renders buttons
ββ handles navigation / API calls
π§± Step 1: Define actions in the TEMPLATE (not in code)¶
NotificationTemplate schema (recommended)¶
ActionDefinition (backend contract)¶
type ActionDefinition = {
id: string; // "pay_now"
label: string; // "Pay Now"
type: 'deeplink' | 'api' | 'route';
payload: JsonObject; // can contain variables
primary?: boolean;
};
Example template record¶
{
"id": "invoice_due",
"body": "Your invoice {{ invoiceNumber }} is due",
"actions": [
{
"id": "pay_now",
"label": "Pay Now",
"type": "deeplink",
"payload": {
"url": "app://payments/{{ invoiceId }}"
},
"primary": true
},
{
"id": "view_invoice",
"label": "View Invoice",
"type": "route",
"payload": {
"screen": "InvoiceDetails",
"params": {
"invoiceId": "{{ invoiceId }}"
}
}
}
]
}
π Important
- Actions are data
- Variables ({{ invoiceId }}) are allowed
- No logic in templates
π§± Step 2: Store actions with UserNotification (optional but recommended)¶
When creating a user notification:
You have two valid choices:
Option A (BEST): Resolve actions at creation time¶
- Store fully resolved actions
- Safer for replay & audit
Option B: Resolve at read-time¶
- Flexible
- Requires rendering logic later
I recommend Option A.
π§± Step 3: Resolve variables in actions (backend)¶
Just like body rendering β but for JSON.
Safe variable resolution function¶
function renderJsonTemplate<T extends object>(
template: T,
variables: Record<string, unknown>
): T {
const json = JSON.stringify(template);
const rendered = renderTemplate(json, variables); // Handlebars
return JSON.parse(rendered);
}
Now during notification creation:
Store resolvedActions in DB.
π§± Step 4: Return actions in GetNotificationsForUserQuery¶
Extend DTO¶
NotificationDto {
id
title
body
actions: {
id: string
label: string
type: string
payload: JsonObject
primary?: boolean
}[]
}
Backend returns¶
data only¶
{
"body": "Your invoice INV-001 is due",
"actions": [
{
"id": "pay_now",
"label": "Pay Now",
"type": "deeplink",
"payload": {
"url": "app://payments/123"
},
"primary": true
}
]
}
π§± Step 5: Frontend / Mobile rendering (clean separation)¶
Mobile app logic¶
actions.forEach(action => {
switch (action.type) {
case 'deeplink':
openDeepLink(action.payload.url);
break;
case 'route':
navigate(action.payload.screen, action.payload.params);
break;
case 'api':
callApi(action.payload.endpoint);
break;
}
});
π Frontend:
- does not parse templates
- does not guess behavior
- only executes declared actions
π« What NOT to do (very important)¶
β Hardcode buttons in frontend
β Infer action from message text
β Put UI labels in code
β Store rendered HTML buttons
β Store logic in templates
π§ Why this approach is the best¶
| Concern | Solved |
|---|---|
| Backend-driven UX | β |
| Localization-ready | β |
| Audit & replay | β |
| Mobile & web reuse | β |
| Versioning | β |
| No frontend guesswork | β |
π₯ Final golden rules¶
- Actions are data
- Templates declare intent
- Variables resolve once
- Clients only execute
- No UI logic in backend
π§© Mapping this to YOUR current code¶
In your current file:
You should:
- β stop doing string.replace
- β return actions from DB
- β render body + actions centrally (use-case or worker)
If you want, next I can:
- refactor your existing query to support actions
- design the DB schema migration
- wire this into your BullMQ notification worker
- give you mobile UI examples
Just tell me which one you want next.