Automatic Document Sharing: From Child to Grandparent Records in Salesforce

TL;DR

  • Hierarchical Salesforce data often requires document visibility across multiple levels.
  • A clean Trigger Action Framework can automatically share documents from child to grandparent records.
  • This architecture prevents duplicate shares, handles bulk operations, and ensures code is maintainable, performant, and testable.

The Business Challenge That Keeps Admins Awake

Sarah, a Salesforce admin at a healthcare organization, is facing a recurring, frustrating problem. Case managers constantly complain they can't see documents uploaded to Assessment records. "The documents are there," frustrated users tell her, "but I have to manually share each one with the Case. It's taking hours every day!"

The technical reality? Document visibility doesn't automatically flow up hierarchical relationships in Salesforce. The platform's security model requires an explicit ContentDocumentLink for each record that needs access.

Understanding the Data Architecture

In complex Salesforce orgs, you often encounter multi-level hierarchies.

Key Points:

  • Cases can have multiple Assessments.
  • Each Assessment can have multiple Documents.
  • Document visibility does not automatically flow upward.

The Problem: When users upload documents to Assessment records, Case managers can't access them without manual sharing—a workflow killer in high-volume environments.

The solution must elegantly handle these complications:

  • Multiple Assessments can belong to the same Case.
  • The same document shouldn't create duplicate shares.
  • Only specific Assessment record types should trigger this behavior.
  • Bulk operations must perform efficiently without hitting governor limits.

The Technical Solution: Clean Architecture Principles

Our approach leverages the Trigger Action Framework to break complex logic into focused, testable components. This pattern is often the key to building scalable, maintainable enterprise solutions.

This separation ensures each piece of logic has a single responsibility, making the code maintainable and debuggable.


Data Model

This diagram provides a high-level view of the key Salesforce objects involved in the solution and how they relate to each other. It clarifies the relationships and highlights the central role of the ContentDocumentLink object in controlling file visibility. It also includes the custom metadata type used for dynamic configuration.

Key Relationships Explained:

  • Case to Assessment: A Case can have one or many Assessment records, represented by a one-to-many relationship. The Case__c field on the Assessment object links it to its parent.
  • Assessment to ContentDocumentLink: A single Assessment record can have multiple documents linked to it. Each link is a ContentDocumentLink record, pointing to the Assessment via the LinkedEntityId field.
  • ContentDocument to ContentDocumentLink: A single ContentDocument (the file itself) can be shared with multiple records. The ContentDocumentLink object acts as the junction, connecting the file to all the records that need access.
  • Document_Sharing_Config to Assessment: The Document_Sharing_Config__mdt custom metadata type holds the RecordTypeId of the Assessment records that should be processed. This is a configurable lookup, allowing the business logic to be updated without code changes.

Implementation Deep Dive

Let's explore the core components of this solution, starting with a clean trigger and progressing to the focused helper methods that do the heavy lifting.

The Foundation: Trigger Setup

The trigger itself is minimal, adhering to the best practice of delegating all logic to a handler class.

trigger ContentDocumentLinkTrigger on ContentDocumentLink (after insert) {
    TriggerHandler.execute(ContentDocumentLinkTriggerAction.class);
}
The Core Handler Class

This class orchestrates the entire process. Notice how the main method reads like a story—each step is clear and focused, setting the stage for the helper methods.

public class ContentDocumentLinkTriggerAction implements ITriggerAction {
    private static final String TARGET_RECORD_TYPE_ID = 'Your_Assessment_RecordType_Id';
    
    public void afterInsert(List<SObject> newList) {
        handleAfterInsert((List<ContentDocumentLink>)newList);
    }
    
    private void handleAfterInsert(List<ContentDocumentLink> newLinks) {
        try {
            // Step 1: Map Assessments to their parent Cases
            Map<Id, Id> assessmentToCaseMap = getAssessmentToCaseMap(newLinks);
            if(assessmentToCaseMap.isEmpty()) return;
            
            // Step 2: Check for existing document shares to prevent duplicates
            Map<String, ContentDocumentLink> existingLinks = getExistingLinks(newLinks, assessmentToCaseMap);
            
            // Step 3: Create new Case links only where needed
            List<ContentDocumentLink> newCaseLinks = createCaseLinks(newLinks, assessmentToCaseMap, existingLinks);
            
            // Step 4: Bulk insert the new shares
            if(!newCaseLinks.isEmpty()) {
                insert newCaseLinks;
            }
        } catch(Exception e) {
            handleError(e);
        }
    }
}
Critical Helper Method 1: Relationship Mapping

This block of methods is dedicated to efficiently finding the parent Case for each relevant Assessment record.

private Map<Id, Id> getAssessmentToCaseMap(List<ContentDocumentLink> newLinks) {
    Set<Id> assessmentIds = getAssessmentIds(newLinks);
    List<Assessment__c> assessments = queryAssessments(assessmentIds);
    return createAssessmentToCaseMap(assessments);
}

private Set<Id> getAssessmentIds(List<ContentDocumentLink> links) {
    Set<Id> assessmentIds = new Set<Id>();
    for(ContentDocumentLink link : links) {
        // Only process links to Assessment records
        if(String.valueOf(link.LinkedEntityId).startsWith(Assessment__c.SObjectType.getDescribe().getKeyPrefix())) {
            assessmentIds.add(link.LinkedEntityId);
        }
    }
    return assessmentIds;
}

private List<Assessment__c> queryAssessments(Set<Id> assessmentIds) {
    return [SELECT Id, Case__c 
            FROM Assessment__c 
            WHERE Id IN :assessmentIds 
            AND RecordTypeId = :TARGET_RECORD_TYPE_ID
            AND Case__c != null];
}

private Map<Id, Id> createAssessmentToCaseMap(List<Assessment__c> assessments) {
    Map<Id, Id> assessmentToCaseMap = new Map<Id, Id>();
    for(Assessment__c assessment : assessments) {
        assessmentToCaseMap.put(assessment.Id, assessment.Case__c);
    }
    return assessmentToCaseMap;
}
Critical Helper Method 2: Duplicate Prevention

This logic is the core of our duplicate prevention strategy. We build a composite key (documentId-caseId) to perform a single query that checks for all existing links.

private Map<String, ContentDocumentLink> getExistingLinks(
    List<ContentDocumentLink> newLinks, 
    Map<Id, Id> assessmentToCaseMap
) {
    Map<String, Id> combinationsToCheck = new Map<String, Id>();
    
    for(ContentDocumentLink cdl : newLinks) {
        Id caseId = assessmentToCaseMap.get(cdl.LinkedEntityId);
        if(caseId != null) {
            String key = generateLinkKey(cdl.ContentDocumentId, caseId);
            combinationsToCheck.put(key, caseId);
        }
    }
    
    return queryExistingLinks(combinationsToCheck);
}

private String generateLinkKey(Id contentDocumentId, Id linkedEntityId) {
    return contentDocumentId + '-' + linkedEntityId;
}

private Map<String, ContentDocumentLink> queryExistingLinks(Map<String, Id> combinationsToCheck) {
    Map<String, ContentDocumentLink> existingLinksMap = new Map<String, ContentDocumentLink>();
    
    if(combinationsToCheck.isEmpty()) return existingLinksMap;
    
    Set<Id> contentDocumentIds = new Set<Id>();
    Set<Id> caseIds = new Set<Id>();
    
    for(String key : combinationsToCheck.keySet()) {
        List<String> parts = key.split('-');
        contentDocumentIds.add(Id.valueOf(parts[0]));
        caseIds.add(combinationsToCheck.get(key));
    }
    
    List<ContentDocumentLink> existingLinks = [
        SELECT ContentDocumentId, LinkedEntityId 
        FROM ContentDocumentLink 
        WHERE ContentDocumentId IN :contentDocumentIds 
        AND LinkedEntityId IN :caseIds
    ];
    
    for(ContentDocumentLink link : existingLinks) {
        String key = generateLinkKey(link.ContentDocumentId, link.LinkedEntityId);
        existingLinksMap.put(key, link);
    }
    
    return existingLinksMap;
}
Critical Helper Method 3: Creating New Shares

This method iterates through the new links, checks against the map of existing links, and only creates new ContentDocumentLink records where they don't already exist.

private List<ContentDocumentLink> createCaseLinks(
    List<ContentDocumentLink> newLinks,
    Map<Id, Id> assessmentToCaseMap,
    Map<String, ContentDocumentLink> existingLinks
) {
    List<ContentDocumentLink> newCaseLinks = new List<ContentDocumentLink>();
    
    for(ContentDocumentLink cdl : newLinks) {
        Id caseId = assessmentToCaseMap.get(cdl.LinkedEntityId);
        
        if(caseId != null) {
            String key = generateLinkKey(cdl.ContentDocumentId, caseId);
            
            if(!existingLinks.containsKey(key)) {
                newCaseLinks.add(createNewLink(cdl, caseId));
            }
        }
    }
    
    return newCaseLinks;
}

private ContentDocumentLink createNewLink(ContentDocumentLink sourceLink, Id caseId) {
    return new ContentDocumentLink(
        ContentDocumentId = sourceLink.ContentDocumentId,
        LinkedEntityId = caseId,
        ShareType = sourceLink.ShareType,
        Visibility = sourceLink.Visibility
    );
}

The Testing Strategy That Saves Projects

A robust testing strategy is non-negotiable for a solution like this. It must cover both positive cases and critical edge cases like duplicate prevention.

graph TD
    subgraph Test Flow: Multiple Assessments
        A[Create Test Case]
        B[Create 3 Assessments for the Case]
        C[Create 1 Document]
        D[Insert 3 ContentDocumentLinks to the Assessments]
        E[Assert: 1 new ContentDocumentLink created for the Case]
    end
    
    A --> B
    B --> C
    C --> D
    D --> E
    
    note right of A
        The TestSetup method
        creates the parent record.
    end
    note right of D
        This step triggers our Apex
        and attempts to create 3 links
        to the same parent Case.
    end
    note right of E
        This assertion verifies our
        duplicate prevention logic.
    end
Test Code Example

This test class verifies that a document uploaded to a child Assessment record is correctly shared with its grandparent Case and that no duplicate links are created even when multiple child records point to the same document.

@IsTest
public class ContentDocumentLinkTriggerActionTest {
    @TestSetup
    static void setupTestData() {
        // Create test Case
        Case testCase = new Case(
            Subject = 'Test Case',
            Status = 'New'
        );
        insert testCase;
        
        // Create test Assessments
        List<Assessment__c> assessments = new List<Assessment__c>();
        for(Integer i = 0; i < 3; i++) {
            assessments.add(new Assessment__c(
                Name = 'Test Assessment ' + i,
                Case__c = testCase.Id,
                RecordTypeId = ContentDocumentLinkTriggerAction.TARGET_RECORD_TYPE_ID
            ));
        }
        insert assessments;
    }
    
    @IsTest
    static void testDocumentShareWithCase() {
        // Arrange
        Case testCase = [SELECT Id FROM Case LIMIT 1];
        Assessment__c assessment = [SELECT Id FROM Assessment__c LIMIT 1];
        
        ContentVersion cv = new ContentVersion(
            Title = 'Test Document',
            PathOnClient = 'test.pdf',
            VersionData = Blob.valueOf('Test content')
        );
        insert cv;
        
        ContentDocument cd = [SELECT Id FROM ContentDocument WHERE LatestPublishedVersionId = :cv.Id];
        
        // Act
        Test.startTest();
        ContentDocumentLink cdl = new ContentDocumentLink(
            ContentDocumentId = cd.Id,
            LinkedEntityId = assessment.Id,
            ShareType = 'V',
            Visibility = 'AllUsers'
        );
        insert cdl;
        Test.stopTest();
        
        // Assert
        List<ContentDocumentLink> caseLinks = [
            SELECT Id FROM ContentDocumentLink 
            WHERE ContentDocumentId = :cd.Id 
            AND LinkedEntityId = :testCase.Id
        ];
        
        System.assertEquals(1, caseLinks.size(), 'Document should be shared with Case');
    }
    
    @IsTest
    static void testNoDuplicateShares() {
        // Test that multiple assessments don't create duplicate case shares
        Case testCase = [SELECT Id FROM Case LIMIT 1];
        List<Assessment__c> assessments = [SELECT Id FROM Assessment__c WHERE Case__c = :testCase.Id];
        
        ContentVersion cv = new ContentVersion(
            Title = 'Test Document',
            PathOnClient = 'test.pdf',
            VersionData = Blob.valueOf('Test content')
        );
        insert cv;
        
        ContentDocument cd = [SELECT Id FROM ContentDocument WHERE LatestPublishedVersionId = :cv.Id];
        
        Test.startTest();
        List<ContentDocumentLink> links = new List<ContentDocumentLink>();
        for(Assessment__c assessment : assessments) {
            links.add(new ContentDocumentLink(
                ContentDocumentId = cd.Id,
                LinkedEntityId = assessment.Id,
                ShareType = 'V',
                Visibility = 'AllUsers'
            ));
        }
        insert links;
        Test.stopTest();
        
        List<ContentDocumentLink> caseLinks = [
            SELECT Id FROM ContentDocumentLink 
            WHERE ContentDocumentId = :cd.Id 
            AND LinkedEntityId = :testCase.Id
        ];
        
        System.assertEquals(1, caseLinks.size(), 'Should only create one Case link regardless of Assessment count');
    }
}

Why This Architecture Works

  • Maintainability: Small, focused methods are easy to understand and modify. The clear separation of concerns makes debugging straightforward and reduces complexity.
  • Performance: The solution uses minimal SOQL queries (typically 2-3 per transaction) and leverages bulk processing throughout, ensuring it operates efficiently and stays within governor limits.
  • Reliability: The use of a composite key strategy for duplicate prevention and proper error handling makes the solution robust and dependable.
  • Scalability: This framework-based architecture is designed to handle bulk operations and scales predictably with data growth and organizational complexity.

Configuration Best Practices

To make this solution truly robust and adaptable, use declarative features for configuration instead of hard-coding values.

  1. Use Custom Metadata Types: Instead of a static constant, use a Custom Metadata Type to store configurable values like the RecordTypeId or the ShareType.
    Document_Sharing_Config__mdt config = Document_Sharing_Config__mdt.getInstance('Assessment_to_Case');
    String recordTypeId = config.Source_Record_Type_Id__c;
    String shareType = config.Default_Share_Type__c;
    
  2. Implement Proper Logging: Use a logging framework to capture and report errors gracefully.
    private void handleError(Exception e) {
        Logger.error('ContentDocumentLink sharing failed', e)
               .setClassName('ContentDocumentLinkTriggerAction')
               .setMethodName('handleAfterInsert')
               .log();
    }
    
  3. Add Configuration Validation: Ensure that critical configurations exist to prevent runtime failures.
    private void validateConfiguration() {
        if(String.isBlank(TARGET_RECORD_TYPE_ID)) {
            throw new ConfigurationException('TARGET_RECORD_TYPE_ID must be configured');
        }
    }
    

Conclusion: Building for the Long Term

This solution demonstrates how complex Salesforce requirements can be addressed through clean architecture principles. By focusing on single responsibility methods, bulk processing, and comprehensive testing, we've created a maintainable solution that scales with organizational growth. The key insight? Break complex problems into simple, focused pieces. Each method in our solution does one thing well, making the entire system reliable and debuggable.


FAQ: Automatic Document Sharing in Salesforce

What are governor limits in Salesforce?

Governor limits are runtime limits enforced by the Salesforce platform to ensure that no single piece of code or process monopolizes shared resources. This multi-tenant architecture is a fundamental aspect of the Salesforce platform. The limits include the number of SOQL queries, DML statements, and heap size. Salesforce enforces these limits to guarantee a consistent and reliable experience for all users.

What is ContentDocument and ContentDocumentLink?

ContentDocument is the parent object representing a document or file in Salesforce. ContentDocumentLink is a junction object that links a ContentDocument to a specific record (e.g., an Account, a Case, or a custom object). A single ContentDocument can be linked to multiple records via multiple ContentDocumentLink records. It's the ContentDocumentLink record that determines visibility.

How is file sharing and visibility controlled in Salesforce?

File sharing and visibility are controlled by the ContentDocumentLink object. The LinkedEntityId field specifies which record the file is linked to, while the ShareType and Visibility fields define the level of access. For example, ShareType can be 'V' for Viewer, 'C' for Collaborator, or 'I' for Inferred, and Visibility can be 'AllUsers', 'InternalUsers', or 'SharedUsers'.

Why can't I just create a Process Builder or Flow for this?

While declarative tools like Flows can be powerful, they have limitations when it comes to document sharing. ContentDocumentLink has some unique behaviors, and the logic to handle bulk operations and prevent duplicates efficiently often requires the programmatic control offered by Apex. Using a trigger and a clean architecture also makes the solution more resilient to platform changes and governor limits.

What are the governor limit considerations for this solution?

The primary governor limits to be mindful of are SOQL queries and DML statements. Our solution minimizes both by using a bulk-first approach. The logic uses only a few SOQL queries (e.g., one to find parent records and one to check for existing links) and one DML statement to insert all new links, regardless of the number of documents uploaded. This ensures the solution stays well within the limits.

How do I handle great-grandparent or deeper hierarchical relationships?

For deeper hierarchies, you can extend the pattern by creating a helper method that traverses the parent-child relationships. For example, to find a great-grandparent record, you would first query the parent, then use the parent's ID to query the grandparent, and so on. The key is to perform these queries efficiently and in a bulk-safe manner, using a single query for each level of the hierarchy.

Why is preventing duplicate ContentDocumentLink records so important?

Preventing duplicates is crucial for performance and data integrity. While Salesforce won't allow two ContentDocumentLink records with the exact same ContentDocumentId and LinkedEntityId to exist, attempting to insert duplicates is inefficient and can cause runtime exceptions in some contexts. The composite key strategy ensures we only attempt to create shares that don't already exist, saving on processing time and DML operations.


Building robust Salesforce solutions requires balancing business needs with technical excellence. Want to see more patterns for scalable Salesforce development? Follow along for deep dives into enterprise-grade solutions.

Comments (0)

Loading comments...