- Introduced provisioning triggers in OrderCdcSubscriber to handle specific status changes and enqueue provisioning jobs. - Implemented request coalescing in OrdersCacheService and CatalogCacheService to prevent duplicate Salesforce API calls during cache invalidation. - Updated CatalogModule and OrdersModule to export additional services for improved module integration. - Enhanced error handling and logging in various services to provide better insights during operations.
7.9 KiB
7.9 KiB
CDC-Only Order Provisioning (Alternative Approach)
Overview
This document shows how to use ONLY CDC (OrderCdcSubscriber) for both cache invalidation AND order provisioning, eliminating the need for Platform Events.
Modified OrderCdcSubscriber
import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../services/salesforce-connection.service";
import { OrdersCacheService } from "@bff/modules/orders/services/orders-cache.service";
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue";
@Injectable()
export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
// ... (same client setup as before)
private readonly PROVISION_STATUSES = new Set(["Approved", "Reactivate"]);
constructor(
private readonly config: ConfigService,
private readonly sfConnection: SalesforceConnection,
private readonly ordersCache: OrdersCacheService,
private readonly provisioningQueue: ProvisioningQueueService, // ← Add this
@Inject(Logger) private readonly logger: Logger
) {}
private async handleOrderEvent(data: unknown): Promise<void> {
const payload = this.extractPayload(data);
const changedFields = this.extractChangedFields(payload);
const orderId = this.extractStringField(payload, ["Id"]);
const accountId = this.extractStringField(payload, ["AccountId"]);
if (!orderId) {
this.logger.warn("Order CDC event missing Order ID; skipping");
return;
}
// 1. CHECK FOR PROVISIONING TRIGGER
if (changedFields.has("Status")) {
await this.handleStatusChange(payload, orderId, changedFields);
}
// 2. CACHE INVALIDATION (existing logic)
const hasCustomerFacingChange = this.hasCustomerFacingChanges(changedFields);
if (hasCustomerFacingChange) {
this.logger.log("Order CDC event with customer-facing changes, invalidating cache", {
orderId,
accountId,
changedFields: Array.from(changedFields),
});
await this.ordersCache.invalidateOrder(orderId);
if (accountId) {
await this.ordersCache.invalidateAccountOrders(accountId);
}
} else {
this.logger.debug("Order CDC event contains only internal field changes; skipping invalidation", {
orderId,
changedFields: Array.from(changedFields),
});
}
}
/**
* Handle Status field changes and trigger provisioning if needed
*/
private async handleStatusChange(
payload: Record<string, unknown>,
orderId: string,
changedFields: Set<string>
): Promise<void> {
const newStatus = this.extractStringField(payload, ["Status"]);
const activationStatus = this.extractStringField(payload, ["Activation_Status__c"]);
// Guard: Only provision for specific statuses
if (!newStatus || !this.PROVISION_STATUSES.has(newStatus)) {
this.logger.debug("Status changed but not a provision trigger", {
orderId,
newStatus,
});
return;
}
// Guard: Don't trigger if already provisioning/provisioned
if (activationStatus === "Activating" || activationStatus === "Activated") {
this.logger.debug("Order already provisioning/provisioned, skipping", {
orderId,
activationStatus,
});
return;
}
// Guard: Check if WHMCS Order ID already exists (idempotency)
const whmcsOrderId = this.extractStringField(payload, ["WHMCS_Order_ID__c"]);
if (whmcsOrderId) {
this.logger.log("Order already has WHMCS Order ID, skipping provisioning", {
orderId,
whmcsOrderId,
});
return;
}
// Trigger provisioning
this.logger.log("Order status changed to provision trigger, enqueuing fulfillment", {
orderId,
status: newStatus,
activationStatus,
});
try {
await this.provisioningQueue.enqueue({
sfOrderId: orderId,
idempotencyKey: `cdc-status-change-${Date.now()}-${orderId}`,
correlationId: `cdc-${orderId}`,
});
this.logger.log("Successfully enqueued provisioning job from CDC", {
orderId,
trigger: "Status change to " + newStatus,
});
} catch (error) {
this.logger.error("Failed to enqueue provisioning job from CDC", {
orderId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// ... (rest of the existing methods)
}
Comparison
Before (Platform Event + CDC):
Salesforce Flow:
- Order Status = "Approved"
- Publish Order_Fulfilment_Requested__e
↓
Portal (SalesforcePubSubSubscriber):
- Receives Platform Event
- Enqueues provisioning job
Portal (OrderCdcSubscriber):
- Receives OrderChangeEvent
- Invalidates cache
After (CDC Only):
Salesforce:
- Order Status = "Approved"
↓
Portal (OrderCdcSubscriber):
- Receives OrderChangeEvent
- Checks: Status changed to "Approved"?
- Yes → Enqueues provisioning job
- Also → Invalidates cache (if customer-facing)
Benefits of CDC-Only
-
✅ Simpler Salesforce Setup
- No Platform Event definition needed
- No Flow to publish event
- Just enable CDC on Order object
-
✅ Single Mechanism
- One subscriber handles everything
- Fewer moving parts
- Easier to understand
-
✅ Automatic
- Any Status change to "Approved" triggers provisioning
- No manual Flow maintenance
Drawbacks of CDC-Only
-
❌ Less Explicit Control
- No clear "provision this order" signal
- Inferred from field changes
- Harder to add custom context
-
❌ More Guards Needed
- Must check: Not already provisioning
- Must check: WHMCS Order ID doesn't exist
- Must check: Activation Status
- More defensive coding
-
❌ Business Logic in Portal
- Portal must know when to provision
- Salesforce can't pre-validate
- Logic duplicated
-
❌ No Custom Metadata
- Can't include: RequestedBy, Priority, etc.
- Limited to Order fields
- Less context for debugging
When to Use CDC-Only
✅ Use CDC-Only if:
- Simple approval workflow (Draft → Approved → Completed)
- No complex business validation needed
- Want simplest possible setup
- Okay with less control
❌ Stick with Platform Events if:
- Complex approval workflows
- Need custom context/metadata
- Multiple trigger conditions
- Business logic should stay in Salesforce
Migration Steps
If you decide to switch to CDC-Only:
-
Update OrderCdcSubscriber
- Add
ProvisioningQueueServicedependency - Add
handleStatusChange()method - Add guards for idempotency
- Add
-
Remove SalesforcePubSubSubscriber (optional)
- Or keep it for other Platform Events
- Just don't subscribe to Order_Fulfilment_Requested__e
-
Remove Salesforce Flow
- Delete Flow that publishes Order_Fulfilment_Requested__e
- Or disable it
-
Test Thoroughly
- Test: Status → "Approved" triggers provisioning
- Test: Already provisioned orders don't re-trigger
- Test: Cancelled orders don't trigger
- Test: Cache invalidation still works
-
Update .env
# Can remove (if not using other Platform Events) # SF_PROVISION_EVENT_CHANNEL=/event/Order_Fulfilment_Requested__e # Keep these (still needed for CDC) SF_ORDER_CDC_CHANNEL=/data/OrderChangeEvent SF_ORDER_ITEM_CDC_CHANNEL=/data/OrderItemChangeEvent
Recommendation
For your use case, I recommend keeping the Platform Event approach:
Reasons:
- You have complex approval workflows
- You want explicit control over provisioning
- Salesforce handles business validation
- Clear separation of concerns:
- Platform Event = "Do something" (provisioning)
- CDC = "Something changed" (cache invalidation)
The dual-mechanism approach is more robust for production systems.