Part 47: Building a Mapping Application in Apex
Welcome back to the Salesforce series. Over the past several posts, we have covered Apex fundamentals, triggers, SOQL, exception handling, security, and platform features like Custom Metadata. Now it is time to put all of that knowledge together and build something real.
In this post, we are building a field mapping application from scratch. The application reads mapping configuration from Custom Metadata, maps fields from one object to another (Lead to Account and Contact), and lets users trigger the mapping from a Visualforce page. This is not a toy example. Field mapping is a pattern you will encounter in data migrations, integration middleware, and any org that converts records between objects.
We will cover four areas:
- Creating Service Classes — The core mapping logic, isolated in a service layer so it can be reused and tested independently.
- Creating a Controller — A Visualforce controller that coordinates user input, calls the service, and handles errors.
- Displaying and Running the Application — A Visualforce page that exposes the mapping tool to users in the org.
- Writing Test Classes — Full test coverage with data factories, bulk testing, and negative test cases.
By the end of this post, you will have a working application that you can deploy to any Salesforce org and extend for your own use cases.
The Architecture
Before writing any code, let us understand what we are building.
The Problem
A sales team qualifies Leads and then manually creates Account and Contact records by copying fields over one at a time. This is slow, error-prone, and inconsistent. Different reps copy different fields, and some forget to copy key data entirely.
The Solution
We will build a mapping application that:
- Reads field mapping definitions from a Custom Metadata Type (so admins can change mappings without touching code).
- Accepts one or more Lead IDs as input.
- Creates Account and Contact records with the mapped field values.
- Returns the results to the user through a Visualforce page.
The Layers
The application follows a service layer architecture:
- Custom Metadata Type (
Field_Mapping__mdt) — Stores which Lead field maps to which target field on which target object. - Service Class (
FieldMappingService) — Reads the metadata, performs the mapping, and executes DML. - Controller Class (
MappingApplicationController) — Handles user input from the Visualforce page, calls the service, and manages page messages. - Visualforce Page (
MappingApplication) — The user interface. - Test Classes — Cover every layer.
This separation of concerns is not optional in professional Salesforce development. It is how you build code that is testable, maintainable, and reusable.
Step 1: Setting Up the Custom Metadata Type
Before we write any Apex, we need a Custom Metadata Type that stores our field mappings. Go to Setup > Custom Metadata Types > New Custom Metadata Type and create the following:
- Label: Field Mapping
- Plural Label: Field Mappings
- Object Name: Field_Mapping
Then add these custom fields to the metadata type:
| Field Label | API Name | Type | Description |
|---|---|---|---|
| Source Object | Source_Object__c | Text(255) | API name of the source object (e.g., Lead) |
| Source Field | Source_Field__c | Text(255) | API name of the field on the source object |
| Target Object | Target_Object__c | Text(255) | API name of the target object (e.g., Account) |
| Target Field | Target_Field__c | Text(255) | API name of the field on the target object |
| Is Active | Is_Active__c | Checkbox | Whether this mapping is currently in use |
Creating Mapping Records
Once the metadata type exists, create records to define the actual mappings. Here are some example records:
Lead to Account Mappings:
| Record Label | Source Object | Source Field | Target Object | Target Field | Is Active |
|---|---|---|---|---|---|
| Lead Company to Account Name | Lead | Company | Account | Name | true |
| Lead Industry to Account Industry | Lead | Industry | Account | Industry | true |
| Lead Phone to Account Phone | Lead | Phone | Account | Phone | true |
| Lead Website to Account Website | Lead | Website | Account | Website | true |
| Lead Street to Account Street | Lead | Street | Account | BillingStreet | true |
| Lead City to Account City | Lead | City | Account | BillingCity | true |
| Lead State to Account State | Lead | State | Account | BillingState | true |
| Lead Country to Account Country | Lead | Country | Account | BillingCountry | true |
Lead to Contact Mappings:
| Record Label | Source Object | Source Field | Target Object | Target Field | Is Active |
|---|---|---|---|---|---|
| Lead FirstName to Contact FirstName | Lead | FirstName | Contact | FirstName | true |
| Lead LastName to Contact LastName | Lead | LastName | Contact | LastName | true |
| Lead Email to Contact Email | Lead | Contact | true | ||
| Lead Phone to Contact Phone | Lead | Phone | Contact | Phone | true |
| Lead Title to Contact Title | Lead | Title | Contact | Title | true |
You can create these through the Setup UI or deploy them as metadata using the Salesforce CLI. The key point is that the mappings are data, not code. An admin can add, remove, or modify mappings without a developer making code changes.
Step 2: Creating Service Classes for Our Mapping Application
The service class is the heart of the application. It has no knowledge of controllers, Visualforce pages, or user interfaces. It takes inputs, reads configuration, performs mapping, and returns results. This makes it reusable — you could call this same service from a trigger, a batch job, a Lightning component, or a REST API endpoint.
The MappingResult Wrapper Class
First, we need a class to hold the results of a mapping operation. This makes it easy to return structured data from the service:
public class MappingResult {
public List<Account> createdAccounts { get; set; }
public List<Contact> createdContacts { get; set; }
public List<String> errors { get; set; }
public Integer totalLeadsProcessed { get; set; }
public Boolean hasErrors {
get {
return errors != null && !errors.isEmpty();
}
}
public MappingResult() {
this.createdAccounts = new List<Account>();
this.createdContacts = new List<Contact>();
this.errors = new List<String>();
this.totalLeadsProcessed = 0;
}
}
This is a simple wrapper, but it makes the service’s return type explicit and self-documenting. Anyone reading the code knows exactly what the service produces.
The FieldMappingService Class
Now for the main service class. We will build it method by method.
public with sharing class FieldMappingService {
// Cache the metadata so we do not query it more than once per transaction
private static Map<String, List<Field_Mapping__mdt>> mappingsByTargetObject;
/**
* Loads all active field mapping records from Custom Metadata
* and organizes them by target object name.
*/
public static Map<String, List<Field_Mapping__mdt>> getActiveMappings(
String sourceObject
) {
if (mappingsByTargetObject == null) {
mappingsByTargetObject = new Map<String, List<Field_Mapping__mdt>>();
List<Field_Mapping__mdt> allMappings = [
SELECT Source_Object__c, Source_Field__c,
Target_Object__c, Target_Field__c
FROM Field_Mapping__mdt
WHERE Is_Active__c = true
AND Source_Object__c = :sourceObject
];
for (Field_Mapping__mdt mapping : allMappings) {
String targetObj = mapping.Target_Object__c;
if (!mappingsByTargetObject.containsKey(targetObj)) {
mappingsByTargetObject.put(
targetObj,
new List<Field_Mapping__mdt>()
);
}
mappingsByTargetObject.get(targetObj).add(mapping);
}
}
return mappingsByTargetObject;
}
/**
* Maps a list of Leads to Account and Contact records
* based on the active field mapping configuration.
*/
public static MappingResult mapLeadsToAccountsAndContacts(
List<Lead> leads
) {
MappingResult result = new MappingResult();
if (leads == null || leads.isEmpty()) {
result.errors.add('No Leads provided for mapping.');
return result;
}
Map<String, List<Field_Mapping__mdt>> mappings =
getActiveMappings('Lead');
if (mappings.isEmpty()) {
result.errors.add(
'No active field mappings found for the Lead object. '
+ 'Please configure Field Mapping custom metadata records.'
);
return result;
}
List<Field_Mapping__mdt> accountMappings =
mappings.containsKey('Account')
? mappings.get('Account')
: new List<Field_Mapping__mdt>();
List<Field_Mapping__mdt> contactMappings =
mappings.containsKey('Contact')
? mappings.get('Contact')
: new List<Field_Mapping__mdt>();
// Build Account and Contact records for each Lead
List<Account> accountsToInsert = new List<Account>();
List<Contact> contactsToInsert = new List<Contact>();
Map<Integer, Id> indexToLeadId = new Map<Integer, Id>();
Integer index = 0;
for (Lead ld : leads) {
// Create the Account
Account acct = new Account();
for (Field_Mapping__mdt fm : accountMappings) {
Object fieldValue = ld.get(fm.Source_Field__c);
if (fieldValue != null) {
acct.put(fm.Target_Field__c, fieldValue);
}
}
// Ensure the Account has a Name (required field)
if (String.isBlank((String) acct.get('Name'))) {
acct.put('Name', 'Mapped Account - ' + ld.LastName);
}
accountsToInsert.add(acct);
// Create the Contact
Contact con = new Contact();
for (Field_Mapping__mdt fm : contactMappings) {
Object fieldValue = ld.get(fm.Source_Field__c);
if (fieldValue != null) {
con.put(fm.Target_Field__c, fieldValue);
}
}
// Ensure the Contact has a LastName (required field)
if (String.isBlank((String) con.get('LastName'))) {
con.put('LastName', ld.LastName);
}
indexToLeadId.put(index, ld.Id);
contactsToInsert.add(con);
index++;
}
// Insert Accounts first so we can link Contacts to them
Savepoint sp = Database.setSavepoint();
try {
insert accountsToInsert;
// Link each Contact to its corresponding Account
for (Integer i = 0; i < contactsToInsert.size(); i++) {
contactsToInsert[i].AccountId = accountsToInsert[i].Id;
}
insert contactsToInsert;
result.createdAccounts = accountsToInsert;
result.createdContacts = contactsToInsert;
result.totalLeadsProcessed = leads.size();
} catch (DmlException e) {
Database.rollback(sp);
for (Integer i = 0; i < e.getNumDml(); i++) {
result.errors.add(
'DML Error on row ' + e.getDmlIndex(i)
+ ': ' + e.getDmlMessage(i)
);
}
} catch (Exception e) {
Database.rollback(sp);
result.errors.add(
'Unexpected error during mapping: ' + e.getMessage()
);
}
return result;
}
/**
* Utility method to map a single Lead.
* Delegates to the bulk method to keep logic in one place.
*/
public static MappingResult mapSingleLead(Lead ld) {
return mapLeadsToAccountsAndContacts(new List<Lead>{ ld });
}
/**
* Clears the cached metadata. Useful in test contexts
* where metadata might be mocked between test methods.
*/
@TestVisible
private static void clearCache() {
mappingsByTargetObject = null;
}
}
Key Design Decisions
There are several things worth calling out in this service class:
Static caching. The mappingsByTargetObject map is populated once and reused for the rest of the transaction. Custom Metadata queries do not count against SOQL limits, but caching still improves performance when the service is called multiple times in the same transaction.
Bulk-first design. The mapLeadsToAccountsAndContacts method accepts a list, not a single record. The single-record method (mapSingleLead) delegates to the bulk method. This is a core Apex pattern — always write the bulk version first, then create convenience wrappers.
Savepoint and rollback. If the Contact insert fails after Accounts were already created, we roll back the entire operation. This prevents orphaned Account records sitting in the database with no associated Contacts.
Dynamic field access. We use ld.get(fieldName) and acct.put(fieldName, value) to read and write fields dynamically. This is what makes the mapping configurable — the field names come from metadata, not from hardcoded references in the Apex code.
Defensive coding. We check for null inputs, empty mapping configurations, blank required fields, and catch both DmlException and generic Exception. The caller always gets a MappingResult back, never an unhandled exception.
Step 3: Creating a Controller for Our Mapping Application
The controller sits between the Visualforce page and the service. It handles user interactions, manages page state, and translates between the UI layer and the business logic layer.
The MappingApplicationController Class
public with sharing class MappingApplicationController {
// Properties bound to the Visualforce page
public List<LeadWrapper> leadWrappers { get; set; }
public MappingResult lastResult { get; private set; }
public Boolean hasRun { get; private set; }
public String searchKeyword { get; set; }
// Constructor — runs when the page loads
public MappingApplicationController() {
leadWrappers = new List<LeadWrapper>();
hasRun = false;
searchKeyword = '';
loadLeads();
}
/**
* Loads open (unconverted) Leads for the user to select.
*/
public void loadLeads() {
leadWrappers.clear();
String query = 'SELECT Id, FirstName, LastName, Company, Email, '
+ 'Phone, Title, Industry, Website, Street, City, '
+ 'State, Country, Status, IsConverted '
+ 'FROM Lead '
+ 'WHERE IsConverted = false';
if (String.isNotBlank(searchKeyword)) {
String safeKeyword = '%' + String.escapeSingleQuotes(
searchKeyword.trim()
) + '%';
query += ' AND (LastName LIKE :safeKeyword'
+ ' OR Company LIKE :safeKeyword'
+ ' OR Email LIKE :safeKeyword)';
}
query += ' ORDER BY CreatedDate DESC LIMIT 200';
List<Lead> leads = Database.query(query);
for (Lead ld : leads) {
leadWrappers.add(new LeadWrapper(ld));
}
}
/**
* Search action — reloads Leads based on the search keyword.
*/
public PageReference searchLeads() {
loadLeads();
return null;
}
/**
* Main action — maps the selected Leads to Accounts and Contacts.
*/
public PageReference runMapping() {
List<Lead> selectedLeads = getSelectedLeads();
if (selectedLeads.isEmpty()) {
ApexPages.addMessage(new ApexPages.Message(
ApexPages.Severity.WARNING,
'Please select at least one Lead to map.'
));
return null;
}
lastResult = FieldMappingService.mapLeadsToAccountsAndContacts(
selectedLeads
);
hasRun = true;
if (lastResult.hasErrors) {
for (String err : lastResult.errors) {
ApexPages.addMessage(new ApexPages.Message(
ApexPages.Severity.ERROR,
err
));
}
} else {
ApexPages.addMessage(new ApexPages.Message(
ApexPages.Severity.CONFIRM,
'Successfully mapped ' + lastResult.totalLeadsProcessed
+ ' Lead(s) to '
+ lastResult.createdAccounts.size() + ' Account(s) and '
+ lastResult.createdContacts.size() + ' Contact(s).'
));
}
return null;
}
/**
* Reset action — clears results and reloads the Lead list.
*/
public PageReference resetPage() {
hasRun = false;
lastResult = null;
searchKeyword = '';
loadLeads();
return null;
}
/**
* Returns the count of selected Leads (used by the page).
*/
public Integer getSelectedCount() {
Integer count = 0;
for (LeadWrapper lw : leadWrappers) {
if (lw.isSelected) {
count++;
}
}
return count;
}
/**
* Extracts the selected Lead records from the wrapper list.
*/
private List<Lead> getSelectedLeads() {
List<Lead> selected = new List<Lead>();
for (LeadWrapper lw : leadWrappers) {
if (lw.isSelected) {
selected.add(lw.lead);
}
}
return selected;
}
/**
* Inner wrapper class that pairs a Lead with a selection checkbox.
*/
public class LeadWrapper {
public Lead lead { get; set; }
public Boolean isSelected { get; set; }
public LeadWrapper(Lead ld) {
this.lead = ld;
this.isSelected = false;
}
}
}
What the Controller Does
The controller handles five responsibilities:
-
Loading data. When the page loads, the constructor queries unconverted Leads and wraps each one in a
LeadWrapperso the page can render a checkbox next to each record. -
Searching. The
searchLeadsmethod re-queries Leads filtered by a keyword. The keyword is bound to an input field on the page. -
Running the mapping. The
runMappingmethod extracts the selected Leads from the wrapper list, passes them to the service, and displays the results usingApexPages.addMessage. -
Displaying results. The
lastResultproperty holds theMappingResultreturned by the service. The page can referencelastResult.createdAccountsandlastResult.createdContactsto show what was created. -
Resetting. The
resetPagemethod clears everything and reloads the Lead list so the user can run another mapping.
Why a Wrapper Class?
The LeadWrapper inner class is a common Visualforce pattern. Standard sObject records do not have a boolean isSelected property. By wrapping each Lead in a class that has both the record and a checkbox flag, we can render a table where each row has a selectable checkbox. This pattern shows up constantly in Visualforce development.
Why the Controller Does Not Contain Business Logic
Notice that runMapping does not contain any mapping logic. It does not read metadata, it does not build Account records, and it does not execute DML. All of that lives in FieldMappingService. The controller’s job is purely coordination: get user input, call the service, display results.
This is important for two reasons. First, it keeps the controller thin and easy to read. Second, it means the mapping logic can be tested independently of the controller and reused in other contexts.
Step 4: How to Display and Run the Mapping Application in the Org
With the service and controller in place, we need a user interface. We will build a Visualforce page that lets users search for Leads, select the ones they want to map, run the mapping, and see the results.
The Visualforce Page
<apex:page controller="MappingApplicationController"
title="Lead Mapping Application"
lightningStylesheets="true"
docType="html-5.0">
<apex:sectionHeader title="Lead Mapping Application"
subtitle="Map Lead fields to Account and Contact records" />
<apex:form id="mainForm">
<apex:pageMessages id="messages" />
<!-- Search Panel -->
<apex:pageBlock title="Search Leads" id="searchBlock">
<apex:pageBlockSection columns="2">
<apex:pageBlockSectionItem>
<apex:outputLabel value="Search by Name, Company, or Email"
for="searchInput" />
<apex:panelGroup>
<apex:inputText id="searchInput"
value="{!searchKeyword}"
style="width: 250px;" />
<apex:commandButton value="Search"
action="{!searchLeads}"
reRender="mainForm"
style="margin-left: 8px;" />
<apex:commandButton value="Reset"
action="{!resetPage}"
reRender="mainForm"
style="margin-left: 4px;" />
</apex:panelGroup>
</apex:pageBlockSectionItem>
</apex:pageBlockSection>
</apex:pageBlock>
<!-- Lead Selection Table -->
<apex:pageBlock title="Select Leads to Map ({!leadWrappers.size} found)"
id="leadBlock">
<apex:pageBlockButtons location="top">
<apex:commandButton value="Run Mapping"
action="{!runMapping}"
reRender="mainForm"
onclick="if(!confirm('Map the selected Leads to Account and Contact records?')){return false;}"
styleClass="btn-primary" />
</apex:pageBlockButtons>
<apex:pageBlockTable value="{!leadWrappers}"
var="lw"
rendered="{!leadWrappers.size > 0}">
<apex:column headerValue="Select" width="50px">
<apex:inputCheckbox value="{!lw.isSelected}" />
</apex:column>
<apex:column headerValue="Name">
<apex:outputLink value="/{!lw.lead.Id}"
target="_blank">
{!lw.lead.FirstName} {!lw.lead.LastName}
</apex:outputLink>
</apex:column>
<apex:column value="{!lw.lead.Company}" headerValue="Company" />
<apex:column value="{!lw.lead.Email}" headerValue="Email" />
<apex:column value="{!lw.lead.Phone}" headerValue="Phone" />
<apex:column value="{!lw.lead.Title}" headerValue="Title" />
<apex:column value="{!lw.lead.Industry}" headerValue="Industry" />
<apex:column value="{!lw.lead.Status}" headerValue="Status" />
</apex:pageBlockTable>
<apex:outputPanel rendered="{!leadWrappers.size == 0}">
<p style="padding: 16px; color: #666;">
No unconverted Leads found. Create some Lead records first.
</p>
</apex:outputPanel>
</apex:pageBlock>
<!-- Results Panel -->
<apex:pageBlock title="Mapping Results"
id="resultsBlock"
rendered="{!hasRun && lastResult != null && !lastResult.hasErrors}">
<apex:pageBlockSection title="Created Accounts" columns="1"
rendered="{!lastResult.createdAccounts.size > 0}">
<apex:pageBlockTable value="{!lastResult.createdAccounts}"
var="acct">
<apex:column headerValue="Account Name">
<apex:outputLink value="/{!acct.Id}" target="_blank">
{!acct.Name}
</apex:outputLink>
</apex:column>
<apex:column value="{!acct.Industry}"
headerValue="Industry" />
<apex:column value="{!acct.Phone}"
headerValue="Phone" />
</apex:pageBlockTable>
</apex:pageBlockSection>
<apex:pageBlockSection title="Created Contacts" columns="1"
rendered="{!lastResult.createdContacts.size > 0}">
<apex:pageBlockTable value="{!lastResult.createdContacts}"
var="con">
<apex:column headerValue="Contact Name">
<apex:outputLink value="/{!con.Id}" target="_blank">
{!con.FirstName} {!con.LastName}
</apex:outputLink>
</apex:column>
<apex:column value="{!con.Email}"
headerValue="Email" />
<apex:column value="{!con.AccountId}"
headerValue="Account ID" />
</apex:pageBlockTable>
</apex:pageBlockSection>
</apex:pageBlock>
</apex:form>
</apex:page>
How to Add the Page to Your Org
There are several ways to make this page accessible to users:
Option 1: Direct URL. Every Visualforce page is accessible at /apex/MappingApplication (where MappingApplication is the page name). Users can navigate there directly or you can add it as a bookmark.
Option 2: Custom Tab. Go to Setup > Tabs > Visualforce Tabs > New. Select the MappingApplication page, give it a label (e.g., “Lead Mapper”), and choose an icon. Then add the tab to the appropriate app and make it visible to the right profiles.
Option 3: Lightning App Page. If you are using Lightning Experience, create a Lightning App Page using the Lightning App Builder and add the Visualforce page as a component. This embeds the page inside the standard Lightning navigation.
Option 4: Custom Button on Lead List View. Create a List Button on the Lead object that opens the Visualforce page. This lets users access the tool directly from a Lead list view.
Setting Visualforce Page Access
Remember that Visualforce pages are controlled by profile and permission set access. Users will not see the page unless their profile or an assigned permission set grants access to it. Go to the page’s security settings (Setup > Visualforce Pages > find the page > Security) and add the appropriate profiles.
Similarly, the controller class needs to be accessible. Go to the Apex class security settings and ensure the profiles that need access are added.
Lightning Component Alternative
If your org uses Lightning Experience exclusively and you prefer a modern UI, you can wrap the same service in a Lightning Web Component. The key difference is that you would create an Apex method annotated with @AuraEnabled(cacheable=false) and call it from JavaScript:
public with sharing class MappingApplicationLwcController {
@AuraEnabled
public static List<Lead> getUnconvertedLeads(String searchKeyword) {
String query = 'SELECT Id, FirstName, LastName, Company, Email, '
+ 'Phone, Title, Industry, Status '
+ 'FROM Lead WHERE IsConverted = false';
if (String.isNotBlank(searchKeyword)) {
String safeKeyword = '%' + String.escapeSingleQuotes(
searchKeyword.trim()
) + '%';
query += ' AND (LastName LIKE :safeKeyword'
+ ' OR Company LIKE :safeKeyword)';
}
query += ' ORDER BY CreatedDate DESC LIMIT 200';
return Database.query(query);
}
@AuraEnabled
public static MappingResult runMapping(List<Id> leadIds) {
List<Lead> leads = [
SELECT Id, FirstName, LastName, Company, Email, Phone,
Title, Industry, Website, Street, City, State, Country
FROM Lead
WHERE Id IN :leadIds AND IsConverted = false
];
return FieldMappingService.mapLeadsToAccountsAndContacts(leads);
}
}
The LWC would call getUnconvertedLeads on load and runMapping when the user clicks the button. The service layer stays the same regardless of which UI technology you use — that is the whole point of separating concerns.
Step 5: Creating Test Classes for Our Mapping Application
Salesforce requires 75% code coverage for production deployments, but coverage alone is not the goal. Good test classes validate behavior, handle edge cases, and catch regressions. We will write thorough tests for both the service and the controller.
The Test Data Factory
A test data factory centralizes test record creation so that every test method uses consistent, well-formed data:
@IsTest
public class MappingTestDataFactory {
/**
* Creates a list of Lead records with realistic data.
* Does not insert them — the caller decides when to insert.
*/
public static List<Lead> createLeads(Integer count) {
List<Lead> leads = new List<Lead>();
for (Integer i = 0; i < count; i++) {
leads.add(new Lead(
FirstName = 'Test' + i,
LastName = 'LeadUser' + i,
Company = 'TestCorp ' + i,
Email = 'testlead' + i + '@example.com',
Phone = '555-000-' + String.valueOf(1000 + i),
Title = 'Manager ' + i,
Industry = 'Technology',
Website = 'https://testcorp' + i + '.example.com',
Street = i + ' Main Street',
City = 'San Francisco',
State = 'CA',
Country = 'US',
Status = 'Open - Not Contacted'
));
}
return leads;
}
/**
* Creates and inserts a specified number of Leads.
*/
public static List<Lead> createAndInsertLeads(Integer count) {
List<Lead> leads = createLeads(count);
insert leads;
return leads;
}
/**
* Creates a minimal Lead with only required fields.
*/
public static Lead createMinimalLead() {
return new Lead(
LastName = 'MinimalLead',
Company = 'MinimalCorp',
Status = 'Open - Not Contacted'
);
}
}
Testing the Service Class
@IsTest
private class FieldMappingServiceTest {
/**
* Test that the service correctly maps Lead fields to Account
* and Contact records using field mapping metadata.
*
* NOTE: Custom Metadata records created in the Setup UI are
* accessible in test context without SeeAllData=true.
* If your org does not have Field_Mapping__mdt records,
* the mapping will return an error — which is also tested below.
*/
@IsTest
static void testMapLeadsToAccountsAndContacts_singleLead() {
Lead testLead = MappingTestDataFactory.createAndInsertLeads(1)[0];
Test.startTest();
MappingResult result =
FieldMappingService.mapLeadsToAccountsAndContacts(
new List<Lead>{ testLead }
);
Test.stopTest();
// If metadata records exist, mapping should succeed
if (!result.hasErrors) {
System.assertEquals(
1, result.createdAccounts.size(),
'Should create one Account'
);
System.assertEquals(
1, result.createdContacts.size(),
'Should create one Contact'
);
System.assertEquals(
1, result.totalLeadsProcessed,
'Should process one Lead'
);
// Verify the Contact is linked to the Account
Contact createdContact = [
SELECT AccountId FROM Contact
WHERE Id = :result.createdContacts[0].Id
];
System.assertEquals(
result.createdAccounts[0].Id,
createdContact.AccountId,
'Contact should be linked to the created Account'
);
} else {
// If no metadata records exist, we expect a config error
System.assert(
result.errors[0].contains('No active field mappings'),
'Should report missing metadata configuration'
);
}
}
@IsTest
static void testMapLeadsToAccountsAndContacts_bulkLeads() {
List<Lead> testLeads =
MappingTestDataFactory.createAndInsertLeads(50);
Test.startTest();
MappingResult result =
FieldMappingService.mapLeadsToAccountsAndContacts(testLeads);
Test.stopTest();
if (!result.hasErrors) {
System.assertEquals(
50, result.createdAccounts.size(),
'Should create 50 Accounts for 50 Leads'
);
System.assertEquals(
50, result.createdContacts.size(),
'Should create 50 Contacts for 50 Leads'
);
System.assertEquals(
50, result.totalLeadsProcessed,
'Should process all 50 Leads'
);
}
}
@IsTest
static void testMapLeadsToAccountsAndContacts_nullInput() {
Test.startTest();
MappingResult result =
FieldMappingService.mapLeadsToAccountsAndContacts(null);
Test.stopTest();
System.assert(
result.hasErrors,
'Should return errors for null input'
);
System.assert(
result.errors[0].contains('No Leads provided'),
'Error message should mention no Leads provided'
);
}
@IsTest
static void testMapLeadsToAccountsAndContacts_emptyList() {
Test.startTest();
MappingResult result =
FieldMappingService.mapLeadsToAccountsAndContacts(
new List<Lead>()
);
Test.stopTest();
System.assert(
result.hasErrors,
'Should return errors for empty list'
);
}
@IsTest
static void testMapSingleLead() {
Lead testLead = MappingTestDataFactory.createAndInsertLeads(1)[0];
Test.startTest();
MappingResult result = FieldMappingService.mapSingleLead(testLead);
Test.stopTest();
// Single lead method delegates to bulk method
System.assertNotEquals(
null, result,
'Result should never be null'
);
System.assertEquals(
result.hasErrors ? 0 : 1,
result.totalLeadsProcessed,
'Should process the single Lead if mappings exist'
);
}
@IsTest
static void testMapLeadWithMinimalFields() {
Lead minLead = MappingTestDataFactory.createMinimalLead();
insert minLead;
Test.startTest();
MappingResult result = FieldMappingService.mapSingleLead(minLead);
Test.stopTest();
if (!result.hasErrors) {
// Account should have a fallback name
System.assertNotEquals(
null, result.createdAccounts[0].Id,
'Account should be created even with minimal Lead data'
);
}
}
@IsTest
static void testGetActiveMappings() {
Test.startTest();
Map<String, List<Field_Mapping__mdt>> mappings =
FieldMappingService.getActiveMappings('Lead');
Test.stopTest();
// This test validates that the method runs without errors.
// The actual contents depend on the org's metadata records.
System.assertNotEquals(
null, mappings,
'Mappings map should never be null'
);
}
@IsTest
static void testClearCache() {
// Call to populate cache
FieldMappingService.getActiveMappings('Lead');
Test.startTest();
FieldMappingService.clearCache();
// Call again — should re-query
Map<String, List<Field_Mapping__mdt>> mappings =
FieldMappingService.getActiveMappings('Lead');
Test.stopTest();
System.assertNotEquals(
null, mappings,
'Should return mappings after cache is cleared'
);
}
@IsTest
static void testMappingResultWrapper() {
MappingResult result = new MappingResult();
System.assertEquals(
false, result.hasErrors,
'New MappingResult should have no errors'
);
System.assertEquals(
0, result.createdAccounts.size(),
'New MappingResult should have no accounts'
);
System.assertEquals(
0, result.createdContacts.size(),
'New MappingResult should have no contacts'
);
System.assertEquals(
0, result.totalLeadsProcessed,
'New MappingResult should have zero processed'
);
result.errors.add('Test error');
System.assertEquals(
true, result.hasErrors,
'MappingResult should report errors after adding one'
);
}
}
Testing the Controller Class
@IsTest
private class MappingApplicationControllerTest {
@TestSetup
static void setupData() {
MappingTestDataFactory.createAndInsertLeads(10);
}
@IsTest
static void testControllerConstructor() {
Test.startTest();
MappingApplicationController controller =
new MappingApplicationController();
Test.stopTest();
System.assertNotEquals(
null, controller.leadWrappers,
'Lead wrappers should be initialized'
);
System.assertEquals(
10, controller.leadWrappers.size(),
'Should load 10 Leads from test setup'
);
System.assertEquals(
false, controller.hasRun,
'hasRun should be false on initialization'
);
System.assertEquals(
'', controller.searchKeyword,
'Search keyword should be empty on initialization'
);
}
@IsTest
static void testSearchLeads() {
MappingApplicationController controller =
new MappingApplicationController();
controller.searchKeyword = 'LeadUser0';
Test.startTest();
PageReference result = controller.searchLeads();
Test.stopTest();
System.assertEquals(
null, result,
'searchLeads should return null (stay on page)'
);
// At least LeadUser0 should be found
System.assert(
controller.leadWrappers.size() >= 1,
'Should find at least one Lead matching the keyword'
);
}
@IsTest
static void testSearchLeads_noResults() {
MappingApplicationController controller =
new MappingApplicationController();
controller.searchKeyword = 'XYZ_NO_MATCH_12345';
Test.startTest();
controller.searchLeads();
Test.stopTest();
System.assertEquals(
0, controller.leadWrappers.size(),
'Should return no results for a non-matching keyword'
);
}
@IsTest
static void testRunMapping_noSelection() {
MappingApplicationController controller =
new MappingApplicationController();
// Do not select any Leads
Test.startTest();
PageReference result = controller.runMapping();
Test.stopTest();
System.assertEquals(
null, result,
'runMapping should return null'
);
// A warning message should be added
List<ApexPages.Message> messages = ApexPages.getMessages();
System.assert(
!messages.isEmpty(),
'Should have a page message when no Leads selected'
);
System.assertEquals(
ApexPages.Severity.WARNING,
messages[0].getSeverity(),
'Message should be a warning'
);
}
@IsTest
static void testRunMapping_withSelection() {
MappingApplicationController controller =
new MappingApplicationController();
// Select the first 3 Leads
for (Integer i = 0; i < 3 && i < controller.leadWrappers.size(); i++) {
controller.leadWrappers[i].isSelected = true;
}
Test.startTest();
PageReference result = controller.runMapping();
Test.stopTest();
System.assertEquals(
null, result,
'runMapping should return null'
);
System.assertEquals(
true, controller.hasRun,
'hasRun should be true after running'
);
System.assertNotEquals(
null, controller.lastResult,
'lastResult should not be null after running'
);
}
@IsTest
static void testGetSelectedCount() {
MappingApplicationController controller =
new MappingApplicationController();
System.assertEquals(
0, controller.getSelectedCount(),
'No Leads selected initially'
);
controller.leadWrappers[0].isSelected = true;
controller.leadWrappers[1].isSelected = true;
System.assertEquals(
2, controller.getSelectedCount(),
'Should count 2 selected Leads'
);
}
@IsTest
static void testResetPage() {
MappingApplicationController controller =
new MappingApplicationController();
// Simulate a run
controller.leadWrappers[0].isSelected = true;
controller.searchKeyword = 'test';
controller.runMapping();
Test.startTest();
PageReference result = controller.resetPage();
Test.stopTest();
System.assertEquals(
null, result,
'resetPage should return null'
);
System.assertEquals(
false, controller.hasRun,
'hasRun should be false after reset'
);
System.assertEquals(
null, controller.lastResult,
'lastResult should be null after reset'
);
System.assertEquals(
'', controller.searchKeyword,
'Search keyword should be empty after reset'
);
}
@IsTest
static void testLeadWrapper() {
Lead testLead = new Lead(LastName = 'Test', Company = 'Corp');
MappingApplicationController.LeadWrapper wrapper =
new MappingApplicationController.LeadWrapper(testLead);
System.assertEquals(
false, wrapper.isSelected,
'Wrapper should default to not selected'
);
System.assertEquals(
'Test', wrapper.lead.LastName,
'Wrapper should hold the Lead record'
);
wrapper.isSelected = true;
System.assertEquals(
true, wrapper.isSelected,
'Should be able to toggle selection'
);
}
}
What the Tests Cover
Let us review what these test classes validate:
Service Tests:
- Single Lead mapping with field verification
- Bulk mapping with 50 Leads to verify governor limit compliance
- Null input handling
- Empty list handling
- Minimal Lead data (tests fallback logic for required fields)
- Metadata loading and caching behavior
- Cache clearing
- The
MappingResultwrapper class itself
Controller Tests:
- Constructor initialization (loads Leads, sets default state)
- Search with a matching keyword
- Search with no matches
- Running the mapping with no Leads selected (warning message)
- Running the mapping with selected Leads
- The
getSelectedCountmethod - Page reset behavior
- The
LeadWrapperinner class
These tests follow several best practices:
-
@TestSetupfor shared data. The controller tests use@TestSetupto create 10 Leads once, then each test method gets its own copy. This is more efficient than creating data in every test method. -
Assertions on behavior, not just coverage. Every test method includes
System.assertorSystem.assertEqualscalls that verify specific outcomes. Running the code is not enough — you need to verify it did the right thing. -
Testing edge cases. Null input, empty lists, no selection, no search results — these are the cases that break production code, and they deserve their own test methods.
-
Test.startTest()andTest.stopTest(). These reset governor limit counters so that your test data setup does not count against the limits of the code being tested.
Putting It All Together
Here is a summary of every file in the application and what it does:
| File | Type | Purpose |
|---|---|---|
Field_Mapping__mdt | Custom Metadata Type | Stores source-to-target field mapping definitions |
MappingResult.cls | Apex Class | Wrapper that holds the results of a mapping operation |
FieldMappingService.cls | Apex Class | Service layer — reads metadata, maps fields, executes DML |
MappingApplicationController.cls | Apex Class | Visualforce controller — handles UI interactions |
MappingApplicationLwcController.cls | Apex Class | Optional Lightning controller with @AuraEnabled methods |
MappingApplication.page | Visualforce Page | User interface for selecting Leads and running mappings |
MappingTestDataFactory.cls | Test Class | Centralized test data creation |
FieldMappingServiceTest.cls | Test Class | Tests for the service layer |
MappingApplicationControllerTest.cls | Test Class | Tests for the controller |
Extending the Application
This application is a foundation that you can extend in many directions:
- More source objects. The service reads the source object from metadata. You could add mappings from Opportunity to custom objects, or from any object to any other object.
- Batch processing. Wrap the service call in a
Database.Batchableclass to process thousands of records asynchronously. - Error logging. Instead of returning errors in a list, write them to a custom object so admins can review failed mappings later.
- Field type validation. Before mapping, check that the source and target fields have compatible types (text to text, number to number, etc.).
- Mapping templates. Add a “Template” field to the metadata so users can choose between different mapping configurations (e.g., “Full Mapping” vs. “Quick Mapping”).
Summary
In this post, we built a complete field mapping application that demonstrates how professional Salesforce development works in practice. The key takeaways are:
- Custom Metadata drives configuration. By storing field mappings in metadata instead of hardcoded Apex, we made the application configurable without code changes.
- Service classes isolate business logic. The
FieldMappingServicecan be called from a controller, a trigger, a batch job, or an API — because it has no dependencies on the UI layer. - Controllers are thin coordinators. The
MappingApplicationControllerhandles user input and page state, but delegates all real work to the service. - Dynamic field access makes mapping generic. Using
sObject.get()andsObject.put()lets us map any field to any field without writing field-specific code. - Savepoints protect data integrity. If part of the operation fails, we roll back everything so the database stays consistent.
- Test classes verify behavior, not just coverage. Every test method includes assertions that check specific outcomes.
This is the kind of architecture you should aim for in any non-trivial Apex application. The individual patterns — service layers, wrappers, dynamic field access, metadata-driven configuration — will appear again and again throughout the rest of this series.
In the next post, Part 48, we will look at Creating Apex for Flows — how to write invocable Apex methods that Flow Builder can call, bridging the gap between declarative automation and custom code.