Chain of Responsibility Pattern in Salesforce LWC: Building a Dynamic Expense Approval System
Introduction
In enterprise applications, approval workflows are a common requirement. Whether it’s expense reports, time-off requests, or purchase orders, these workflows often involve multiple levels of approval based on different criteria. In this blog post, we’ll explore how to implement the Chain of Responsibility design pattern in Salesforce Lightning Web Components (LWC) to create a flexible and maintainable expense approval system.
What is the Chain of Responsibility Pattern?
The Chain of Responsibility pattern is a behavioral design pattern that passes requests along a chain of handlers. Upon receiving a request, each handler decides either to process it or to pass it to the next handler in the chain.
Think of it like a corporate hierarchy:
- An employee submits an expense report
- The Team Lead can approve expenses up to $1,000
- The Manager can approve up to $5,000
- The Director can approve up to $25,000
- The VP handles anything above that
Why Use This Pattern?
- Decoupling: Each approval level operates independently
- Flexibility: Easy to add or remove approval levels
- Single Responsibility: Each handler focuses on one approval threshold
- Maintainability: Simple to modify business rules
Implementation Overview
Let’s break down the implementation into its core components:
- The Handler Class
export class ExpenseHandler {
constructor(limit, role) {
this.limit = limit;
this.role = role;
this.nextHandler = null;
}
setNext(handler) {
this.nextHandler = handler;
return handler;
}
async handleRequest(amount, expenseId) {
if (amount <= this.limit) {
return {
approved: true,
approver: this.role,
message: `Expense of $${amount} approved by ${this.role}`
};
} else if (this.nextHandler) {
return this.nextHandler.handleRequest(amount, expenseId);
}
return {
approved: false,
approver: null,
message: 'No appropriate approver found'
};
}
}
This class defines:
- The approval limit for each handler
- The role responsible for approval
- Logic to pass requests to the next handler
2. The LWC Component
import { LightningElement, api, track } from 'lwc';
import { ExpenseHandler } from './expenseHandler';
import createExpense from '@salesforce/apex/ExpenseController.createExpense';
import updateExpenseStatus from '@salesforce/apex/ExpenseController.updateExpenseStatus';
import getExpense from '@salesforce/apex/ExpenseController.getExpense';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { NavigationMixin } from 'lightning/navigation';
export default class ExpenseApproval extends NavigationMixin(LightningElement) {
@api recordId;
status;
amount = 0; // Initialize amount
isProcessing = false;
errorMessage = '';
// Handle amount input change
handleAmountChange(event) {
const inputAmount = parseFloat(event.target.value);
// Validate input
if (isNaN(inputAmount)) {
this.errorMessage = 'Please enter a valid number';
this.amount = 0;
return;
}
if (inputAmount < 0) {
this.errorMessage = 'Amount cannot be negative';
this.amount = 0;
return;
}
this.errorMessage = '';
this.amount = inputAmount;
}
async handleApprovalRequest() {
if (!this.validateAmount()) {
return;
}
try {
this.isProcessing = true;
this.errorMessage = '';
// First create the expense record
const expenseId = await createExpense({ amount: this.amount });
// Initialize approval chain
const chain = this.initializeChain();
const result = await chain.handleRequest(this.amount, expenseId);
if (result.approved) {
// Update the expense record with approval details
await updateExpenseStatus({
expenseId: expenseId,
approver: result.approver,
status: 'Approved'
});
this.showToast('Success', result.message, 'success');
this.resetForm();
// Navigate to the newly created record
this.navigateToExpenseRecord(expenseId);
// Refresh the record if on a record page
if (this.recordId) {
await this.loadExpenseRecord();
}
} else {
// Update status to rejected
await updateExpenseStatus({
expenseId: expenseId,
approver: 'System',
status: 'Rejected'
});
this.showToast('Error', result.message, 'error');
this.navigateToExpenseRecord(expenseId);
}
} catch (error) {
this.errorMessage = error.message;
console.log('79 @@@ errorMessage ',JSON.stringify(error));
this.showToast('Error', error.message, 'error');
} finally {
this.isProcessing = false;
}
}
// Load existing expense record if we're on a record page
async connectedCallback() {
if (this.recordId) {
await this.loadExpenseRecord();
}
}
async loadExpenseRecord() {
try {
this.expenseRecord = await getExpense({ expenseId: this.recordId });
this.amount = this.expenseRecord.Amount__c;
this.status = this.expenseRecord.Status__c;
} catch (error) {
console.log('99 @@@ errorMessage ',JSON.stringify(error));
this.errorMessage = error.message;
}
}
// Initialize the chain of responsibility
initializeChain() {
const teamLead = new ExpenseHandler(1000, 'Team Lead');
const manager = new ExpenseHandler(5000, 'Manager');
const director = new ExpenseHandler(25000, 'Director');
const vp = new ExpenseHandler(Number.MAX_VALUE, 'VP');
teamLead.setNext(manager);
manager.setNext(director);
director.setNext(vp);
return teamLead;
}
// Validate before submitting
validateAmount() {
if (this.amount <= 0) {
this.errorMessage = 'Amount must be greater than zero';
return false;
}
if (isNaN(this.amount)) {
this.errorMessage = 'Please enter a valid amount';
return false;
}
return true;
}
// Reset form after successful submission
resetForm() {
this.amount = 0;
this.errorMessage = '';
// Reset the input field
const inputField = this.template.querySelector('lightning-input');
if (inputField) {
inputField.value = '';
}
}
showToast(title, message, variant) {
this.dispatchEvent(
new ShowToastEvent({
title: title,
message: message,
variant: variant
})
);
}
// Navigation method to redirect to the expense record
navigateToExpenseRecord(expenseId) {
// Define the page reference for navigation
const pageReference = {
type: 'standard__recordPage',
attributes: {
recordId: expenseId,
objectApiName: 'Expense__c',
actionName: 'view'
}
};
// Navigate to the expense record
this[NavigationMixin.Navigate](pageReference);
}
}
<template>
<lightning-card title="Expense Approval">
<div class="slds-p-around_medium">
<!-- Amount Input -->
<lightning-input
label="Expense Amount"
type="number"
formatter="currency"
step="0.01"
min="0"
value={amount}
onchange={handleAmountChange}
required>
</lightning-input>
<!-- Error Message Display -->
<div if:true={errorMessage} class="slds-text-color_error slds-p-top_small">
{errorMessage}
</div>
<!-- Submit Button -->
<div class="slds-m-top_medium">
<lightning-button
label="Submit for Approval"
onclick={handleApprovalRequest}
variant="brand"
disabled={isProcessing}>
</lightning-button>
</div>
</div>
</lightning-card>
</template>
3. Apex Class
public class ExpenseController {
@AuraEnabled
public static String createExpense(Decimal amount) {
try {
// Security check - verify user has create permission
if (!Schema.sObjectType.Expense__c.isCreateable()) {
throw new AuraHandledException('Insufficient permissions to create expense');
}
// Create new expense record
Expense__c expense = new Expense__c(
Amount__c = amount,
Status__c = 'Pending'
);
insert expense;
return expense.Id;
} catch (Exception e) {
throw new AuraHandledException('Failed to create expense: ' + e.getMessage());
}
}
@AuraEnabled
public static void updateExpenseStatus(
Id expenseId,
String approver,
String status
) {
try {
// Security check - verify user has update permission
if (!Schema.sObjectType.Expense__c.isUpdateable()) {
throw new AuraHandledException('Insufficient permissions to update expense');
}
// Query existing expense to verify it exists
Expense__c expense = [
SELECT Id, Amount__c, Status__c
FROM Expense__c
WHERE Id = :expenseId
WITH SECURITY_ENFORCED
];
// Update expense record
expense.Status__c = status;
expense.Approver__c = approver;
update expense;
} catch (Exception e) {
throw new AuraHandledException('Failed to update expense: ' + e.getMessage());
}
}
@AuraEnabled(cacheable=true)
public static Expense__c getExpense(Id expenseId) {
try {
return [
SELECT Id, Amount__c, Status__c, Approver__c
FROM Expense__c
WHERE Id = :expenseId
WITH SECURITY_ENFORCED
LIMIT 1
];
} catch (Exception e) {
throw new AuraHandledException('Failed to retrieve expense: ' + e.getMessage());
}
}
}
How It Works
User Input:
- User enters expense amount
- Component validates the input
Record Creation:
- Creates new expense record
- Initializes with ‘Pending’ status
Approval Chain:
- Request starts with Team Lead handler
- Each handler checks if amount is within their limit
- If not, passes to next handler
Final Processing:
- Updates record with approval status
- Navigates to the expense record
Key Advantages of the Pattern:
- Decoupling:
- Approval levels are independent
- Changes to one level don’t affect others
- Easy to maintain and modify
- Scalability:
- New approval levels can be added easily
- No need to modify existing code
- Supports complex approval hierarchies
- Maintainability:
- Clear, single-responsibility handlers
- Easy to update business rules
- Simple to debug and test
- Flexibility:
- Can change approval chain at runtime
- Support different chains for different scenarios
- Easy to add new conditions or rules
Note: This is not a production ready code, this article is only for learning purpose