Skip to main content

Build a Project Metadata & Schema Audit Dashboard in Salesforce (Apex & Visualforce)

In plain words: A Project Component Dashboard gives developers and release managers a single-screen inventory of everything inside a project or namespace—including Custom Objects, Permission Sets, Apex Classes, Visualforce Pages, Triggers, and Entity Relationships—without navigating through dozens of Setup menus.

During release preparation, package maintenance, or technical documentation audits, development teams need a fast way to map all components associated with a specific project or managed namespace. By combining Dynamic Apex Schema Describe with SOQL queries against system metadata tables (ApexClass, ApexPage, EntityDefinition, and ObjectPermissions), you can render a real-time inventory dashboard styled with the Salesforce Lightning Design System (SLDS).

1. Architecture: Inspecting Metadata & Schema via Apex

The dashboard controller aggregates information from several platform layers:

  • Schema Describing: Schema.getGlobalDescribe() and Schema.describeSObjects() identify custom objects and extract relationship details (Lookup vs. Master-Detail).
  • Metadata Tables: Queries against ApexClass, ApexTrigger, ApexComponent, and PermissionSet filtered by project prefixes or namespaces.
  • Visualforce Page Markup Parsing: Inspects ApexPage.Markup to dynamically extract standard controllers, custom controllers, and extensions.
  • Entity Definition & Setup URLs: Queries EntityDefinition to generate direct links into the Salesforce Object Manager setup menu.

2. Step 1: Apex Controller Implementation

The controller organizes each component category into wrapper classes and uses the transient keyword on large collections to keep the Visualforce View State lean.

Apex Controller (ProjectComponentsController.cls)
public with sharing class ProjectComponentsController {

    public transient List<DisplayListWrapper> displayList { get; set; }
    public transient List<ApexClass> classList { get; set; }
    public transient List<PageClassWrapper> pageClassList { get; set; }
    public transient List<PermissionSet> permissionSetList { get; set; }
    public transient List<Group> publicGroupList { get; set; }
    public transient List<ApexTrigger> triggerList { get; set; }
    public transient List<ApexComponent> apexComponentList { get; set; }
    public transient List<ObjectRelationshipWrapper> objectRelationList { get; set; }

    // Target project prefix or managed namespace (e.g., 'c2g', 'MyProject')
    private static final String PROJECT_PREFIX = 'c2g';

    public ProjectComponentsController() {
        loadPermissionSetsAndObjects();
        loadApexClasses();
        loadVisualforcePages();
        loadTriggersAndComponents();
        loadObjectRelationships();
    }

    private void loadPermissionSetsAndObjects() {
        displayList = new List<DisplayListWrapper>();
        
        // 1. Fetch project permission sets
        String pSetFilter = '%' + PROJECT_PREFIX + '%';
        permissionSetList = [
            SELECT Id, Name, Label 
            FROM PermissionSet 
            WHERE Name LIKE :pSetFilter OR Label LIKE :pSetFilter 
            WITH USER_MODE 
            ORDER BY Label ASC
        ];
        
        Map<Id, String> pMap = new Map<Id, String>();
        for (PermissionSet ps : permissionSetList) {
            pMap.put(ps.Id, ps.Label);
        }

        // 2. Identify Matching Custom Objects
        Map<String, Schema.SObjectType> allObjects = Schema.getGlobalDescribe();
        List<String> matchedObjectNames = new List<String>();
        for (String key : allObjects.keySet()) {
            if (key.containsIgnoreCase(PROJECT_PREFIX) && key.endsWithIgnoreCase('__c')) {
                matchedObjectNames.add(key);
            }
        }

        if (matchedObjectNames.isEmpty()) {
            return;
        }

        // 3. Describe Object Details
        Schema.DescribeSObjectResult[] describes = Schema.describeSObjects(matchedObjectNames);
        List<String> cleanNames = new List<String>();
        for (Schema.DescribeSObjectResult dr : describes) {
            cleanNames.add(dr.getName().replace('__c', ''));
        }

        // 4. Retrieve Setup Durable IDs for direct linking
        Map<String, String> setupUrlMap = new Map<String, String>();
        for (EntityDefinition entity : [
            SELECT DeveloperName, DurableId 
            FROM EntityDefinition 
            WHERE DeveloperName IN :cleanNames 
            WITH USER_MODE
        ]) {
            setupUrlMap.put(entity.DeveloperName, entity.DurableId);
        }

        // 5. Query Object Permissions
        List<ObjectPermissions> perms = [
            SELECT ParentId, SobjectType 
            FROM ObjectPermissions 
            WHERE SobjectType IN :matchedObjectNames 
            WITH USER_MODE
        ];
        Map<String, Set<String>> objToPermSetNames = new Map<String, Set<String>>();
        for (ObjectPermissions op : perms) {
            if (!objToPermSetNames.containsKey(op.SobjectType)) {
                objToPermSetNames.put(op.SobjectType, new Set<String>());
            }
            if (pMap.containsKey(op.ParentId)) {
                objToPermSetNames.get(op.SobjectType).add(pMap.get(op.ParentId));
            }
        }

        for (Schema.DescribeSObjectResult dr : describes) {
            String clean = dr.getName().replace('__c', '');
            String durableId = setupUrlMap.containsKey(clean) ? setupUrlMap.get(clean) : dr.getName();
            Set<String> permLabels = objToPermSetNames.get(dr.getName());
            String permString = permLabels != null ? String.join(new List<String>(permLabels), ', ') : '--';
            
            displayList.add(new DisplayListWrapper(dr.getLabel(), dr.getName(), durableId, permString));
        }
        displayList.sort();
    }

    private void loadApexClasses() {
        String filter = '%' + PROJECT_PREFIX + '%';
        classList = [
            SELECT Id, Name, ApiVersion, Status 
            FROM ApexClass 
            WHERE Name LIKE :filter 
            WITH USER_MODE 
            ORDER BY Name ASC
        ];
    }

    private void loadVisualforcePages() {
        pageClassList = new List<PageClassWrapper>();
        String filter = '%' + PROJECT_PREFIX + '%';
        
        for (ApexPage page : [
            SELECT Id, Name, Markup 
            FROM ApexPage 
            WHERE Name LIKE :filter 
            WITH USER_MODE 
            ORDER BY Name ASC
        ]) {
            String markup = page.Markup;
            String stdCtrl = extractAttribute(markup, 'standardController');
            String ctrl = extractAttribute(markup, 'controller');
            String ext = extractAttribute(markup, 'extensions');

            pageClassList.add(new PageClassWrapper(page.Id, page.Name, stdCtrl, ctrl, ext));
        }
    }

    private void loadTriggersAndComponents() {
        String filter = '%' + PROJECT_PREFIX + '%';
        triggerList = [SELECT Id, Name, TableEnumOrId FROM ApexTrigger WHERE Name LIKE :filter WITH USER_MODE ORDER BY Name ASC];
        apexComponentList = [SELECT Id, Name FROM ApexComponent WHERE Name LIKE :filter WITH USER_MODE ORDER BY Name ASC];
        publicGroupList = [SELECT Id, Name, DeveloperName FROM Group WHERE Name LIKE :filter WITH USER_MODE ORDER BY Name ASC];
    }

    private void loadObjectRelationships() {
        objectRelationList = new List<ObjectRelationshipWrapper>();
        Map<String, Schema.SObjectType> allObjMap = Schema.getGlobalDescribe();
        List<String> targetObjects = new List<String>();

        for (String name : allObjMap.keySet()) {
            if (name.containsIgnoreCase(PROJECT_PREFIX) && !name.containsIgnoreCase('history') && !name.containsIgnoreCase('share')) {
                targetObjects.add(name);
            }
        }

        if (targetObjects.isEmpty()) return;

        Schema.DescribeSObjectResult[] describeResults = Schema.describeSObjects(targetObjects);
        for (Schema.DescribeSObjectResult dr : describeResults) {
            List<FieldDetailWrapper> fList = new List<FieldDetailWrapper>();
            for (Schema.SObjectField fieldToken : dr.fields.getMap().values()) {
                Schema.DescribeFieldResult fr = fieldToken.getDescribe();
                if (fr.getRelationshipName() != null && !fr.getReferenceTo().isEmpty()) {
                    String relType = fr.getRelationshipOrder() == null ? 'Lookup' : 'Master-Detail';
                    String relCategory = fr.getRelationshipOrder() == 0 ? 'Primary' : (fr.getRelationshipOrder() == 1 ? 'Secondary' : '--');
                    fList.add(new FieldDetailWrapper(String.valueOf(fr.getReferenceTo()[0]), fr.getLabel(), relType, relCategory, fr.getRelationshipName()));
                }
            }
            if (!fList.isEmpty()) {
                objectRelationList.add(new ObjectRelationshipWrapper(dr.getLabel(), fList));
            }
        }
    }

    private String extractAttribute(String markup, String attributeName) {
        if (String.isBlank(markup) || !markup.contains(attributeName + '="')) {
            return '--';
        }
        Integer startIdx = markup.indexOf(attributeName + '="') + (attributeName + '="').length();
        Integer endIdx = markup.indexOf('"', startIdx);
        return (startIdx > 0 && endIdx > startIdx) ? markup.substring(startIdx, endIdx) : '--';
    }

    // Wrapper Classes
    public class DisplayListWrapper implements Comparable {
        public String objectLabel { get; set; }
        public String objectApiName { get; set; }
        public String setupUrl { get; set; }
        public String permissionSets { get; set; }

        public DisplayListWrapper(String label, String apiName, String url, String ps) {
            this.objectLabel = label;
            this.objectApiName = apiName;
            this.setupUrl = url;
            this.permissionSets = ps;
        }

        public Integer compareTo(Object compareTo) {
            DisplayListWrapper other = (DisplayListWrapper) compareTo;
            return this.objectLabel.compareTo(other.objectLabel);
        }
    }

    public class PageClassWrapper {
        public Id pageId { get; set; }
        public String pageName { get; set; }
        public String stdCtrlName { get; set; }
        public String ctrlName { get; set; }
        public String extName { get; set; }

        public PageClassWrapper(Id i, String name, String std, String ctrl, String ext) {
            this.pageId = i;
            this.pageName = name;
            this.stdCtrlName = std;
            this.ctrlName = ctrl;
            this.extName = ext;
        }
    }

    public class ObjectRelationshipWrapper {
        public String objectName { get; set; }
        public List<FieldDetailWrapper> fieldRelations { get; set; }

        public ObjectRelationshipWrapper(String obName, List<FieldDetailWrapper> fList) {
            this.objectName = obName;
            this.fieldRelations = fList;
        }
    }

    public class FieldDetailWrapper {
        public String relatedObjectName { get; set; }
        public String relatedFieldName { get; set; }
        public String relationType { get; set; }
        public String relationCategory { get; set; }
        public String relationshipName { get; set; }

        public FieldDetailWrapper(String robName, String rFldName, String rType, String rCat, String rName) {
            this.relatedObjectName = robName;
            this.relatedFieldName = rFldName;
            this.relationType = rType;
            this.relationCategory = rCat;
            this.relationshipName = rName;
        }
    }
}

3. Step 2: Visualforce Dashboard Markup

The Visualforce page incorporates the Salesforce Lightning Design System (<apex:slds />) and renders responsive tables with direct links to metadata records in Setup.

Visualforce View (ProjectComponentsDashboard.page)
<apex:page controller="ProjectComponentsController" showHeader="false" sidebar="false" standardStylesheets="false" lightningStylesheets="true">
    <apex:slds />
    <div class="slds-scope slds-p-around_medium">
        
        <!-- SLDS Header Banner -->
        <div class="slds-page-header slds-m-bottom_medium">
            <div class="slds-page-header__row">
                <div class="slds-page-header__col-title">
                    <div class="slds-media">
                        <div class="slds-media__body">
                            <h1 class="slds-page-header__title slds-truncate" title="Project Inventory">
                                Project Component & Metadata Inventory
                            </h1>
                            <p class="slds-page-header__name-meta">
                                Centralized Audit of Objects, Code, Pages, and Security
                            </p>
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <!-- Permission Sets Summary -->
        <div class="slds-card slds-m-bottom_medium">
            <div class="slds-card__header slds-grid">
                <header class="slds-media slds-media_center slds-has-flexi-truncate">
                    <div class="slds-media__body">
                        <h2 class="slds-card__header-title">Permission Sets ({!permissionSetList.size})</h2>
                    </div>
                </header>
            </div>
            <div class="slds-card__body slds-card__body_inner">
                <div class="slds-grid slds-wrap">
                    <apex:repeat value="{!permissionSetList}" var="ps">
                        <div class="slds-col slds-size_1-of-3 slds-p-around_xx-small">
                            <a href="/{!ps.Id}" target="_blank" class="slds-text-link">{!ps.Label}</a>
                        </div>
                    </apex:repeat>
                </div>
            </div>
        </div>

        <!-- Custom Objects & Permissions Table -->
        <div class="slds-card slds-m-bottom_medium">
            <div class="slds-card__header slds-grid">
                <header class="slds-media slds-media_center slds-has-flexi-truncate">
                    <div class="slds-media__body">
                        <h2 class="slds-card__header-title">Custom Objects ({!displayList.size})</h2>
                    </div>
                </header>
            </div>
            <div class="slds-card__body">
                <table class="slds-table slds-table_cell-buffer slds-table_bordered slds-table_striped">
                    <thead>
                        <tr class="slds-line-height_reset">
                            <th scope="col">Object Label</th>
                            <th scope="col">API Name</th>
                            <th scope="col">Associated Permission Sets</th>
                        </tr>
                    </thead>
                    <tbody>
                        <apex:repeat value="{!displayList}" var="obj">
                            <tr>
                                <td><a href="/lightning/setup/ObjectManager/{!obj.setupUrl}/view" target="_blank">{!obj.objectLabel}</a></td>
                                <td><code class="ap-code">{!obj.objectApiName}</code></td>
                                <td>{!obj.permissionSets}</td>
                            </tr>
                        </apex:repeat>
                    </tbody>
                </table>
            </div>
        </div>

        <!-- Visualforce Pages and Controllers -->
        <div class="slds-card slds-m-bottom_medium">
            <div class="slds-card__header slds-grid">
                <header class="slds-media slds-media_center slds-has-flexi-truncate">
                    <div class="slds-media__body">
                        <h2 class="slds-card__header-title">Visualforce Pages ({!pageClassList.size})</h2>
                    </div>
                </header>
            </div>
            <div class="slds-card__body">
                <table class="slds-table slds-table_cell-buffer slds-table_bordered">
                    <thead>
                        <tr class="slds-line-height_reset">
                            <th scope="col">Page Name</th>
                            <th scope="col">Standard Controller</th>
                            <th scope="col">Custom Controller</th>
                            <th scope="col">Extensions</th>
                        </tr>
                    </thead>
                    <tbody>
                        <apex:repeat value="{!pageClassList}" var="p">
                            <tr>
                                <td><a href="/{!p.pageId}" target="_blank">{!p.pageName}</a></td>
                                <td><code class="ap-code">{!p.stdCtrlName}</code></td>
                                <td><code class="ap-code">{!p.ctrlName}</code></td>
                                <td><code class="ap-code">{!p.extName}</code></td>
                            </tr>
                        </apex:repeat>
                    </tbody>
                </table>
            </div>
        </div>

    </div>
</apex:page>
360 Component Architecture Card:
  • View State Optimization: Declared collections as transient to prevent bloated 135 KB View State limits.
  • Schema Sizing: Used Schema.describeSObjects(List<String>) in bulk rather than individual describe calls in loops to avoid CPU time timeouts.
  • Deep Linking: Linked object labels directly to Lightning Object Manager using EntityDefinition.DurableId.
  • Security Enforcement: Enforced User Mode on all metadata queries using WITH USER_MODE.

4. Common Traps & Best Practices

Developer Trap: Memory Heap Limit & Global Describe
Calling Schema.getGlobalDescribe() instantiates metadata tokens for every standard and custom object in the org, consuming roughly 1 to 3 MB of heap space. Never execute global describes multiple times within the same transaction. Cache the result in a local map variable.
Core Takeaway: Always use transient variables in Visualforce controllers when displaying large read-only metadata tables, and describe sObjects in bulk batches using Schema.describeSObjects().
  • Filter Unnecessary Suffixes: Always filter out history (__History), share (__Share), and feed (__Feed) tables when mapping data model relationships.
  • Regex Attribute Parsing: When reading controller attributes from page markup, ensure string indices safely check for closing quotes to avoid substring index out of bounds exceptions.
  • Modern Alternative: For full-scale enterprise audits, combine this approach with the Tooling API or Salesforce CLI (sf project deploy validate) to generate JSON-formatted dependency graphs.

Summary

Building a dynamic project component dashboard in Visualforce provides an instant architectural overview of your application metadata. By querying system tables, evaluating object relationships with Schema describes, and applying clean SLDS styling, you streamline release tracking, onboarding, and compliance reviews across your entire Salesforce organization.