Skip to content

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

public static void processProducts(List<Product2> products, String triggerOperation)

Purpose: Main entry point that processes product records to create or update SEO elements based on trigger operation type (Insert or Update).

Parameters: - products (List): Product records to process for SEO - triggerOperation (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:

  1. Initialize Collections:

    List<ObjectRelatedUrl> slugRecords = new List<ObjectRelatedUrl>();
    List<ObjectMetadataTag> metaTagRecords = new List<ObjectMetadataTag>();
    Set<Id> productIds = new Map<Id, Product2>(products).keySet();
    

  2. 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
            ));
        }
    }
    

  3. Creates slug from product name
  4. Creates title tag from product name
  5. Creates description tag from product description (truncated to 255 chars)

  6. Insert Slug Records:

    if (!slugRecords.isEmpty()) {
        Database.SaveResult[] results = Database.insert(slugRecords, false, AccessLevel.SYSTEM_MODE);
        for (Integer i = 0; i < results.size(); i++) {
            if (!results[i].isSuccess()) {
                System.debug('Failed to insert record: ' + results[i].getErrors()[0].getMessage());
            }
        }
    }
    

  7. Uses allOrNone=false for partial success
  8. Uses SYSTEM_MODE to bypass FLS/sharing
  9. Logs failures to debug

  10. Insert Meta Tag Records:

    if (!metaTagRecords.isEmpty()) {
        Database.SaveResult[] results = Database.insert(metaTagRecords, false, AccessLevel.SYSTEM_MODE);
        // Error logging same as slugs
    }
    

FOR UPDATE OPERATIONS:

  1. Query Existing SEO Records:
    slugRecordsToUpdate = getObjectRelatedUrlRecords(productIds);
    metaTagRecordsToUpdate = getObjectMetadataTagRecords(productIds);
    
  2. Retrieves existing slugs and meta tags for products

  3. Build Maps for Efficient Lookup:

    if (!slugRecordsToUpdate.isEmpty()){
        productIdToObjectRelatedUrlRecord = populateProductIdToObjectRelatedUrlRecord(slugRecordsToUpdate);
    }
    if (!metaTagRecordsToUpdate.isEmpty()){
        productIdToObjectMetadataTagMap = populateProductIdToObjectMetadataTagMap(metaTagRecordsToUpdate);
    }
    

  4. Creates maps for O(1) lookups

  5. Process Each Product:

    for (Product2 product : products) {
        Boolean productTitleTag = false;
        Boolean productDescriptionTag = false;
        String slug = generateSlug(product.Name);
    

  6. 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));
    }
    

  7. Updates existing slug if changed
  8. Creates new slug if missing

  9. 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);
        }
    }
    

  10. Updates existing tags with new values
  11. Tracks which tag types exist

  12. Create Missing Tags:

    if (productTitleTag == false) {
        if (product.Name != '' && product.Name != null) {
            missingMetaTagList.add(new ObjectMetadataTag(
                RecordId = product.Id,
                Language = 'en_US',
                TagType = 'Title',
                Value = product.Name
            ));
        }
    }
    

  13. Creates missing title and description tags

  14. Perform DML Operations:

    Database.update(productIdToObjectRelatedUrlRecord.values(), false, AccessLevel.SYSTEM_MODE);
    Database.update(updatedMetaTagRecords, false, AccessLevel.SYSTEM_MODE);
    Database.insert(missingMetaTagList, false, AccessLevel.SYSTEM_MODE);
    

  15. Uses try-catch for each operation
  16. Logs errors via System.debug

Private/Helper Methods

generateSlug

private static String generateSlug(String name)

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:

return name.trim().toLowerCase().replaceAll('[^a-z0-9\\s]', '').replaceAll('\\s+', '-');

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

private static ObjectRelatedUrl createObjectRelatedUrl(Product2 product, String slug)

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

private static List<ObjectRelatedUrl> getObjectRelatedUrlRecords(Set<Id> productIds)

Purpose: Queries existing ObjectRelatedUrl records for given products.

Parameters: - productIds (Set): Product IDs to query

Returns: List<ObjectRelatedUrl> - Existing slug records

Query:

return [SELECT Id, UrlName, ParentId, LanguageCode, Scope
        FROM ObjectRelatedUrl
        WHERE ParentId IN :productIds];


getObjectMetadataTagRecords

private static List<ObjectMetadataTag> getObjectMetadataTagRecords(Set<Id> productIds)

Purpose: Queries existing ObjectMetadataTag records for given products.

Parameters: - productIds (Set): Product IDs to query

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): Slug records to map

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): Meta tag records to map

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());
}
- Catches exceptions but doesn't rethrow - Continues processing even on failure - No notification to calling code


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