Class Name: ProductSEOHelper¶
Last Updated: 2025-10-22 Source Code: https://github.com/AANP-IT/I2C.Salesforce.Metadata/blob/STAGING/force-app/main/default/classes/ProductSEOHelper.cls
API Name: ProductSEOHelper Type: Trigger Handler / Helper Class Test Coverage: ProductSEOHelperTest.cls Created: 2025-04-24 Author: Antoneac Victor
Business Purpose¶
This helper class automates SEO optimization for AANP's Commerce Cloud product catalog by generating and maintaining SEO-friendly URL slugs and metadata tags when products are created or updated. It creates ObjectRelatedUrl records for clean, search-engine-friendly URLs and ObjectMetadataTag records for title and description metadata, improving product discoverability, search engine rankings, and overall e-commerce performance. This automation ensures consistent SEO implementation across the entire product catalog without requiring manual intervention from marketing or merchandising teams.
Class Overview¶
Scope and Sharing¶
- Sharing Model: with sharing (respects record-level security)
- Access Modifier: public
- Interfaces Implemented: None (trigger handler utility)
Key Responsibilities¶
- Generate SEO-friendly URL slugs from product names
- Create and maintain ObjectRelatedUrl records for products
- Generate and update ObjectMetadataTag records (Title, Description)
- Handle both product insert and update operations
- Maintain SEO data synchronization with product changes
- Support multi-language framework (currently en_US only)
- Perform bulk DML operations with partial success handling
- Crop descriptions to 255-character limit
Public Methods¶
processProducts¶
Purpose: Main entry point that processes product records to create or update SEO elements based on trigger operation type (Insert or Update).
Parameters:
- products (ListtriggerOperation (String): 'Insert' or 'Update' - determines processing logic
Returns: void
Throws: - Does not throw exceptions (uses partial success DML) - Logs errors via System.debug()
Usage Example:
// Called from ProductTrigger:
trigger ProductTrigger on Product2 (after insert, after update) {
if (Trigger.isAfter && Trigger.isInsert) {
ProductSEOHelper.processProducts(Trigger.new, 'Insert');
}
if (Trigger.isAfter && Trigger.isUpdate) {
ProductSEOHelper.processProducts(Trigger.new, 'Update');
}
}
Business Logic:
FOR INSERT OPERATIONS:
-
Initialize Collections:
-
Generate Slugs and Meta Tags:
for (Product2 product : products) { String slug = generateSlug(product.Name); slugRecords.add(createObjectRelatedUrl(product, slug)); if (product.Name != null && product.Name != ''){ metaTagRecords.add(new ObjectMetadataTag( RecordId = product.Id, Language = 'en_US', TagType = 'Title', Value = product.Name )); } if (product.Description != null && product.Description != '') { String croppedDescription = product.Description.length() > 255 ? product.Description.substring(0, 255) : product.Description; metaTagRecords.add(new ObjectMetadataTag( RecordId = product.Id, Language = 'en_US', TagType = 'Description', Value = croppedDescription )); } } - Creates slug from product name
- Creates title tag from product name
-
Creates description tag from product description (truncated to 255 chars)
-
Insert Slug Records:
- Uses allOrNone=false for partial success
- Uses SYSTEM_MODE to bypass FLS/sharing
-
Logs failures to debug
-
Insert Meta Tag Records:
FOR UPDATE OPERATIONS:
- Query Existing SEO Records:
-
Retrieves existing slugs and meta tags for products
-
Build Maps for Efficient Lookup:
-
Creates maps for O(1) lookups
-
Process Each Product:
-
Update or Create Slug:
if (productIdToObjectRelatedUrlRecord.containsKey(product.Id)) { ObjectRelatedUrl currentSlugRecord = productIdToObjectRelatedUrlRecord.get(product.Id); String currentSlug = currentSlugRecord.UrlName; if (slug != currentSlug) { currentSlugRecord.UrlName = slug; productIdToObjectRelatedUrlRecord.put(product.Id, currentSlugRecord); } } else { slugRecordsToUpdate.add(createObjectRelatedUrl(product, slug)); } - Updates existing slug if changed
-
Creates new slug if missing
-
Update or Create Meta Tags:
if (productIdToObjectMetadataTagMap.containsKey(product.Id)) { List<ObjectMetadataTag> currentTags = productIdToObjectMetadataTagMap.get(product.Id); for (ObjectMetadataTag currentTag : currentTags) { if (currentTag.TagType == 'Title') { productTitleTag = true; currentTag.Value = product.Name; } else if (currentTag.TagType == 'Description') { productDescriptionTag = true; currentTag.Value = product.Description; } updatedMetaTagRecords.add(currentTag); } } - Updates existing tags with new values
-
Tracks which tag types exist
-
Create Missing Tags:
-
Creates missing title and description tags
-
Perform DML Operations:
- Uses try-catch for each operation
- Logs errors via System.debug
Private/Helper Methods¶
generateSlug¶
Purpose: Converts product name into SEO-friendly URL slug (lowercase, hyphenated, special characters removed).
Parameters:
- name (String): Product name to convert
Returns: String - SEO-friendly slug or null if name blank
Logic:
Examples: - "Premium Membership 2025" → "premium-membership-2025" - "PAC Contribution ($100)" → "pac-contribution-100" - "Advanced NP Certification" → "advanced-np-certification"
Issues: - No uniqueness check (duplicate slugs possible) - No length validation - Consecutive special chars become nothing: "Foo---Bar" → "foobar"
createObjectRelatedUrl¶
Purpose: Creates ObjectRelatedUrl record with specified slug for product.
Parameters:
- product (Product2): Product record
- slug (String): Generated URL slug
Returns: ObjectRelatedUrl - New slug record (not yet inserted)
Logic:
return new ObjectRelatedUrl(
ParentId = product.Id,
UrlName = slug,
LanguageCode = 'en_US',
Scope = '01t' // ⚠️ Hardcoded - Product2 object ID prefix
);
Hardcoded Values:
- LanguageCode: 'en_US' (no internationalization)
- Scope: '01t' (Product2 object prefix - should be dynamic)
getObjectRelatedUrlRecords¶
Purpose: Queries existing ObjectRelatedUrl records for given products.
Parameters:
- productIds (Set
Returns: List<ObjectRelatedUrl> - Existing slug records
Query:
return [SELECT Id, UrlName, ParentId, LanguageCode, Scope
FROM ObjectRelatedUrl
WHERE ParentId IN :productIds];
getObjectMetadataTagRecords¶
Purpose: Queries existing ObjectMetadataTag records for given products.
Parameters:
- productIds (Set
Returns: List<ObjectMetadataTag> - Existing meta tag records
Query:
return [SELECT Id, RecordId, Language, TagType, Value
FROM ObjectMetadataTag
WHERE RecordId IN :productIds];
populateProductIdToObjectRelatedUrlRecord¶
private static Map<Id, ObjectRelatedUrl> populateProductIdToObjectRelatedUrlRecord(List<ObjectRelatedUrl> slugRecords)
Purpose: Converts list of slug records to map keyed by product ID for efficient lookup.
Parameters:
- slugRecords (List
Returns: Map<Id, ObjectRelatedUrl> - Map of product ID to slug record
Note: Assumes one slug per product (last one wins if multiple exist)
populateProductIdToObjectMetadataTagMap¶
private static Map<Id, List<ObjectMetadataTag>> populateProductIdToObjectMetadataTagMap(List<ObjectMetadataTag> metaTagRecords)
Purpose: Converts list of meta tag records to map of product ID to list of tags.
Parameters:
- metaTagRecords (List
Returns: Map<Id, List<ObjectMetadataTag>> - Map of product ID to its meta tags
Logic:
for (ObjectMetadataTag tag : metaTagRecords) {
if (!productIdToTagsMap.containsKey(tag.RecordId)) {
productIdToTagsMap.put(tag.RecordId, new List<ObjectMetadataTag>());
}
productIdToTagsMap.get(tag.RecordId).add(tag);
}
Dependencies¶
Apex Classes¶
- None - Standalone helper class
- Called by: Product2 trigger
Salesforce Objects¶
Product2 (Standard) - Fields read: Id, Name, Description - Purpose: Source for SEO data generation
ObjectRelatedUrl (Commerce Cloud) - Fields: Id, ParentId, UrlName, LanguageCode, Scope - Purpose: Stores SEO-friendly URL slugs - Created/Updated by this class
ObjectMetadataTag (Commerce Cloud) - Fields: Id, RecordId, Language, TagType, Value - Tag Types: 'Title', 'Description' - Purpose: Stores SEO metadata - Created/Updated by this class
Custom Settings/Metadata¶
- None - Could benefit from:
- SEO_Settings__mdt: Language codes, scope values, slug patterns
- Product_SEO_Config__mdt: Enable/disable per product family
External Services¶
- None - Internal Salesforce processing only
Design Patterns¶
- Helper/Service Pattern: Static utility methods
- Factory Pattern: createObjectRelatedUrl() factory method
- Builder Pattern: Constructs SEO records programmatically
- Partial Success Pattern: Uses allOrNone=false for resilience
- Map/Reduce Pattern: Converts lists to maps for efficient lookups
Why These Patterns: - Helper pattern appropriate for trigger-called utility - Factory pattern encapsulates ObjectRelatedUrl creation - Builder pattern constructs complex SEO records - Partial success prevents one failure from blocking all - Map/reduce optimizes update operations
Governor Limits Considerations¶
SOQL Queries: 2 (only on update: slugs, meta tags) DML Operations: Up to 3 (slugs, updated tags, new tags) CPU Time: Medium (string manipulation, map operations) Heap Size: Medium (collections for SEO records)
Bulkification: Yes - processes lists of products
Async Processing: No (synchronous trigger handler)
Governor Limit Risks: - LOW: 2 SOQL queries well within limits - MEDIUM: 3 DML operations * bulk products could approach 150 DML limit - LOW: String operations on descriptions could consume CPU - LOW: Multiple large collections (maps, lists) could consume heap
Performance Considerations: - Insert is lightweight (no queries, 2 inserts) - Update is more expensive (2 queries + 3 DML operations) - Update logic has nested loops (product → tags) - Large product imports could be slow
Recommendations: 1. Monitor update operations for large bulk updates 2. Consider batching for mass product imports 3. Add configuration to disable for data loads 4. Optimize nested loops if performance degrades
Error Handling¶
Strategy: Partial success DML with debug logging
Logging: - System.debug() for DML failures - No persistent logging - Debug logs not visible in production without enabling - No user-friendly error messages
User Notifications: - None - Errors are silent to users - Product saves succeed even if SEO creation fails - No indication when SEO elements missing
Validation: - Checks name/description not null/blank - No slug uniqueness validation - No language code validation - No scope validation
Rollback Behavior: - Product DML is separate transaction from SEO - Product save succeeds even if SEO fails - Partial SEO success possible (slug ok, tags fail) - No automatic cleanup of orphaned SEO records
Recommended Improvements: 1. HIGH: Implement persistent error logging (custom object) 2. HIGH: Add slug uniqueness validation 3. MEDIUM: Validate hardcoded values (language, scope) 4. MEDIUM: Add retry mechanism for failed SEO creation 5. LOW: Consider throwing exception if critical SEO failure
Try-Catch Blocks:
try {
Database.update(productIdToObjectRelatedUrlRecord.values(), false, AccessLevel.SYSTEM_MODE);
} catch (Exception ex) {
System.debug('Failed to update Object Related Url: ' + ex.getMessage());
}
Security Considerations¶
Sharing Rules: RESPECTED - Uses 'with sharing' - But all DML uses AccessLevel.SYSTEM_MODE (bypasses sharing) - Contradiction: with sharing declaration vs SYSTEM_MODE
Field-Level Security: BYPASSED - Uses AccessLevel.SYSTEM_MODE - All DML bypasses FLS - Can create/update SEO records regardless of user permissions - Appropriate for system automation
CRUD Permissions: BYPASSED - Uses AccessLevel.SYSTEM_MODE - Bypasses object permissions - Appropriate for trigger-driven automation
Input Validation: MINIMAL - Checks name/description not null/blank - No validation of string content - No sanitization of special characters - No length validation beyond description crop
Security Risks: - LOW: SYSTEM_MODE appropriate for SEO automation - LOW: No user input (product data from trigger) - LOW: No sensitive data in SEO fields - NONE: No security implications
SEO Implications: - MEDIUM: Duplicate slugs could cause routing issues - MEDIUM: Special characters in names not fully sanitized - LOW: Very long product names become very long slugs
Mitigation Recommendations: 1. Remove 'with sharing' (misleading given SYSTEM_MODE) 2. Add slug uniqueness validation 3. Add slug length limits 4. Sanitize product names more thoroughly 5. Document SYSTEM_MODE justification
Test Class¶
Test Class: ProductSEOHelperTest.cls Coverage: To be determined
Test Scenarios That Should Be Covered:
✓ Insert Operations: - Product with name and description - Product with name only (no description) - Product with description only (no name) - Product with blank name and description - Product with very long description (>255 chars) - Bulk insert of 200 products
✓ Update Operations: - Update product name (slug should change) - Update product description (meta tag should update) - Update both name and description - Product with existing slug - Product with existing meta tags - Product missing slug (should create) - Product missing meta tags (should create) - Bulk update of 200 products
✓ Slug Generation: - Name with special characters: "Product (Premium)" - Name with multiple spaces: "Product Name" - Name with hyphens: "Pre-Approved Product" - Name with numbers: "Product 2025" - Name with all caps: "PRODUCT NAME" - International characters: "Produit Français"
✓ Description Handling: - Description exactly 255 characters - Description 256 characters (should crop) - Description 500 characters (should crop) - Description with special characters - Description with line breaks
✓ Error Scenarios: - DML failure on slug insert - DML failure on meta tag insert - Query returns no existing records - Duplicate slugs (if validation added)
Testing Challenges: - ObjectRelatedUrl and ObjectMetadataTag may not be available in all test contexts - Difficult to mock Commerce Cloud objects - Partial success DML testing requires careful assertions - Error logging only to System.debug (not verifiable in tests)
Test Data Requirements: - Product2 records with various names and descriptions - Existing ObjectRelatedUrl records - Existing ObjectMetadataTag records - Products with special characters in names
Changes & History¶
- Created: 2025-04-24
- Author: Antoneac Victor
- Purpose: Automate SEO optimization for product catalog
- Related to: Commerce Cloud B2B/B2C implementation
- Integration: Part of Product2 trigger automation
⚠️ Pre-Go-Live Concerns¶
CRITICAL - Fix Before Go-Live¶
- NO SLUG UNIQUENESS: generateSlug() can create duplicate slugs. Products with similar names (e.g., "Product A" and "Product-A") generate same slug. Commerce Cloud routing will fail.
- HARDCODED LANGUAGE: All SEO elements hardcoded to 'en_US'. No internationalization support. Multi-language sites will have wrong language codes.
- HARDCODED SCOPE: ObjectRelatedUrl.Scope = '01t' hardcoded. Should be dynamic or validated. Breaks if object ID prefix changes.
- DESCRIPTION TRUNCATION: Silently truncates descriptions >255 chars. Could cut mid-word or mid-sentence. No user notification.
HIGH - Address Soon After Go-Live¶
- NO ERROR LOGGING: Only System.debug for errors. No persistent logging. Production failures invisible without debug logs enabled.
- CONTRADICTORY SHARING: Declares 'with sharing' but uses SYSTEM_MODE for all DML. Remove 'with sharing' declaration.
- NO SLUG LENGTH VALIDATION: Product names can be very long. No maximum slug length. Could exceed URL path limits.
- INCOMPLETE SANITIZATION: generateSlug() removes special chars but doesn't handle edge cases (consecutive spaces, leading/trailing hyphens).
MEDIUM - Future Enhancement¶
- NO SLUG COLLISION HANDLING: When duplicate slug detected, append number (product-name-2). Currently no collision detection.
- LIMITED META TAGS: Only Title and Description. Consider Keywords, OGTags, canonical URLs for better SEO.
- NO CONFIGURATION: All behavior hardcoded. Add custom metadata for language codes, slug patterns, enabled product families.
- NO RETRY LOGIC: SEO creation failures not retried. Products can exist without SEO indefinitely.
- PERFORMANCE: Update logic has nested loops. Optimize for large bulk updates.
LOW - Monitor¶
- CODE ORGANIZATION: Long processProducts() method (162 lines). Extract insert/update logic to separate methods.
- MISSING NULL CHECKS: product.Description could be null in update. Causes NullPointerException.
- MAP OVERWRITE: populateProductIdToObjectRelatedUrlRecord() overwrites if multiple slugs exist. Should log warning.
- TEST COVERAGE: Ensure comprehensive test coverage for all scenarios.
Maintenance Notes¶
Complexity: Medium (complex update logic, multiple DML operations) Recommended Review Schedule: Monthly, when SEO strategy changes
Key Maintainer Notes:
🔍 CRITICAL SEO ISSUES: - NO SLUG UNIQUENESS: Multiple products can have same slug - Scenario: "Product (A)" and "Product A" both become "product-a" - Impact: Commerce Cloud routing breaks, wrong product displayed - Fix Required: Add uniqueness check + collision handling
📋 Usage Patterns: - Called from Product2 trigger on after insert/update - Runs synchronously in trigger context - Creates/updates 2-4 related records per product - Typical: Processes 1-50 products per transaction - Risk: Large product imports (200+) may be slow
🧪 Testing Requirements: - Test slug generation with special characters - Test description truncation at 255 char boundary - Test bulk operations (200+ products) - Test with existing and missing SEO records - Test DML failure scenarios - Verify duplicate slug handling
🔧 Configuration Dependencies: - Product2.Name must be populated - Product2.Description optional - ObjectRelatedUrl object must exist (Commerce Cloud) - ObjectMetadataTag object must exist (Commerce Cloud) - Commerce store must be configured
⚠️ Gotchas and Warnings: - Language hardcoded to 'en_US' - Scope hardcoded to '01t' (Product2 prefix) - Duplicate slugs not detected - Description truncated at 255 chars without notification - SEO failures are silent (System.debug only) - Uses SYSTEM_MODE despite 'with sharing' declaration - Update is much slower than insert (2 queries + complex logic)
📅 When to Review This Class: - IMMEDIATELY: When duplicate slug errors occur - When implementing internationalization - When SEO strategy changes (new meta tags) - During large product imports - If Commerce Cloud routing issues appear - When adding new product families
🛑 Emergency Deactivation:
// Option 1: Add custom metadata check at start of processProducts()
SEO_Settings__mdt settings = SEO_Settings__mdt.getInstance('Product_SEO');
if (settings == null || !settings.Enabled__c) {
return; // Skip SEO processing
}
// Option 2: Comment out from trigger
// ProductSEOHelper.processProducts(Trigger.new, 'Insert'); // Temporarily disabled
// Option 3: Add bypass for specific product families
if (product.Family == 'Test' || product.Family == 'Internal') {
continue; // Skip SEO for certain families
}
🔍 Debugging Tips:
- Enable debug logs for User executing product DML
- Check ObjectRelatedUrl records: SELECT Id, UrlName, ParentId FROM ObjectRelatedUrl WHERE ParentId = '<product_id>'
- Check ObjectMetadataTag records: SELECT Id, TagType, Value FROM ObjectMetadataTag WHERE RecordId = '<product_id>'
- Search for duplicate slugs: SELECT UrlName, COUNT(Id) FROM ObjectRelatedUrl GROUP BY UrlName HAVING COUNT(Id) > 1
- Test slug generation in Execute Anonymous:
String slug = 'Product (A)'.trim().toLowerCase().replaceAll('[^a-z0-9\\s]', '').replaceAll('\\s+', '-');
System.debug('Slug: ' + slug);
📊 Monitoring Checklist: - Weekly: Duplicate slug detection query - Weekly: Products without slugs (missing SEO) - Monthly: SEO creation error rates (requires custom logging) - Quarterly: Slug quality review (manual sample check) - Alert: Increase in System.debug errors (requires log monitoring) - Alert: Commerce routing errors (may indicate slug issues)
🔗 Related Components: - ProductTrigger: Calls this class - ObjectRelatedUrl: Commerce SEO slug storage - ObjectMetadataTag: Commerce SEO metadata storage - Product2: Source data for SEO - Commerce Storefront: Consumes SEO slugs for routing
Business Owner¶
Primary: Marketing / E-Commerce / SEO Team Secondary: Product Management / Merchandising Stakeholders: Digital Marketing, Web Development, IT Operations