To learn about Person Accounts, the features and their intended use, see training guide from Salesforce Implementing Person Accounts. Cons and Pros of enabling Person Accounts are discussed on many blogs. Business people embrace it but developers tend to stay away. However, it is essential to take Person Account into consideration if the managed package should be “Person Account compatible”, i.e. your managed package should work as expected in a Person Account enabled org. A particular nasty issue is: Inserts, updates, and deletes on Person Accounts fire Account triggers, not Contact triggers. So when your managed package is deployed to a Person Account enabled org, the logic on Contact will not be fired if there is any change on Person Account record. For example, a validation rule is implemented on Contact trigger to prevent the user from updating Birth Date to a future date. This should be applied to Person Account as well if Birth Date is used on Person Account. However, this logic won’t be executed on an update of a Person Account record.
The first intuitive solution coming to the mind is to invoke the Contact triggers from within Account triggers. If there’s a way to do it, I haven’t found it. The next solution would be re-implement the same logic of Contact triggers in Account triggers. But this is an obvious Don’t Repeat Yourself (DRY) principle violation. It is simply duplicate code with minor changes. This post illustrates a way of executing the same logic of Contact triggers on Person Accounts, rather than trying to invoke Contact triggers on Person Accounts. It uses a wrapper to represent either Contact or Account and refactor the Contact triggers logic out to a common component that Account triggers can share. The common component deals with the wrapper objects and does not care if the objects are Contacts or Person Accounts.
Firstly, a ContactWrapper is defined to represent either a Contact object or a Person Account object. The aim is to provide a common interface for retrieving and updating fields values on either Contact or Person Account object. The calling class can be a utility class that implements common logic for Person and does not care if the Person is represented by Contact or Person Account. The common logic usually needs to be invoked by both Contact and Account triggers.
// ContactWrapper.cls public class ContactWrapper { private static final SObjectField CONTACT_BIRTH_DATE = Contact.BirthDate; private SObject sob; private Boolean isPersonAccount; public Date birthDate { get { return (Date) sob.get(getField(CONTACT_BIRTH_DATE)); } set { sob.put(getField(CONTACT_BIRTH_DATE), value); } } public ContactWrapper(Contact c) { if (c == null) { throw new ContactWrapperException('Contact cannot be null.'); } this.sob = (SObject) c; isPersonAccount = false; } public ContactWrapper(Account a) { if (!PersonAccounts.isEnabledForOrg()) { throw new ContactWrapperException('Person Account is not supported for the current org.'); } if (a == null) { throw new ContactWrapperException('Account cannot be null.'); } if (a.get('IsPersonAccount') == false) { throw new ContactWrapperException('Only Person Account can be used by ContactWrapper.'); } this.sob = (SObject) a; isPersonAccount = true; } public SObject getSob() { return sob; } public Object get(String contactFieldName) { String fieldName = contactFieldName; if (isPersonAccount) { fieldName = PersonAccounts.getFieldName(contactFieldName); if (fieldName == null) { throw new ContactWrapperException('Cannot find corresponding Account field for Contact field ' + contactFieldName); } } return sob.get(fieldName); } // Add error message on the SObject. Equivalent to sObject.addError method public void addError(String error) { sob.addError(error); } // Return the name of the field rather than SObjectField because it will hit a strange error like the following: // "Account.BirthDate does not belong to SObject type Account" private String getField(SObjectField contactField) { if (isPersonAccount) { SObjectField accountField = PersonAccounts.getField(contactField); if (accountField == null) { throw new ContactWrapperException('Cannot find corresponding Account field for Contact field ' + String.valueOf(contactField)); } return String.valueOf(accountField); } else { return String.valueOf(contactField); } } public class ContactWrapperException extends Exception {} }
The above sample code only presents field “birthDate”. In practice, there should be much more depending on the fields references in the Contact triggers. As not every field on Contact has a corresponding field on Person Account, e.g. AccountId, ReportsTo, getters and setters for these fields cannot be defined. Logic related to these fields should be exclusively placed in Contact trigger. Another referenced class in the above code is PersonAccounts, a utility class that deals with Person Account general issues.
// PersonAccounts.cls public class PersonAccounts { public class PersonAccountsException extends Exception { } private static final Map<String, SObjectField> ACCOUNT_FIELDS_MAP = Schema.SObjectType.Account.fields.getMap(); private static final Set<String> ACCOUNT_FIELDS_NAMES = ACCOUNT_FIELDS_MAP.keyset(); public static Boolean isEnabledForOrg() { return ACCOUNT_FIELDS_NAMES.contains('personcontactid'); } // Map from Contact field to Account field public static SObjectField getField(SObjectField contactField) { String fieldName = String.valueOf(contactField); // The above fieldName can contain namespace prefix. Trim the prefix before comparing. fieldName = StringUtil.removePrefix(fieldName); fieldName = fieldName.toLowerCase(); // Get the __pc field first if this matches as both Account and Contact can have the same custom field like PaymentMethod__c String pcFieldName = fieldName.replace('__c', '__pc'); if (ACCOUNT_FIELDS_NAMES.contains(pcFieldName)) { return ACCOUNT_FIELDS_MAP.get(pcFieldName); } String personFieldName = 'person' + fieldName; if(ACCOUNT_FIELDS_NAMES.contains(personFieldName)) { return ACCOUNT_FIELDS_MAP.get(personFieldName); } if (ACCOUNT_FIELDS_NAMES.contains(fieldName)) { return ACCOUNT_FIELDS_MAP.get(fieldName); } return null; } // Map from Contact field name to Account field name public static String getFieldName(String contactFieldName) { String fieldName = StringUtil.removePrefix(contactFieldName); fieldName = fieldName.toLowerCase(); String pcFieldName = fieldName.replace('__c', '__pc'); if (ACCOUNT_FIELDS_NAMES.contains(pcFieldName)) { return String.valueOf(ACCOUNT_FIELDS_MAP.get(pcFieldName)); } String personFieldName = 'person' + fieldName; if(ACCOUNT_FIELDS_NAMES.contains(personFieldName)) { return String.valueOf(ACCOUNT_FIELDS_MAP.get(personFieldName)); } if (ACCOUNT_FIELDS_NAMES.contains(fieldName)) { return String.valueOf(ACCOUNT_FIELDS_MAP.get(fieldName)); } return null; } }
The next step is to move all original Contact triggers logic to a common class interfacing with ContactWrapper. This class contains static methods which represent the common logic that should be invoked by both Contact triggers and Person Account triggers (specifically logic in Account triggers that only care about a list of Person Account records). Any logic applied to both Contact and Person Account should be placed in this class. The class should use ContactWrapper objects as representatives of Contacts or Person Accounts.
// ContactAndPersonAccountTriggerLogic.cls public class ContactAndPersonAccountTriggerLogic { public static void runBefore( Boolean isInsert, Boolean isUpdate, Boolean isDelete, List<ContactWrapper> oldList, Map<Id, ContactWrapper> oldMap, List<ContactWrapper> newList, Map<Id, ContactWrapper> newMap) { // "before trigger" logic common to Contacts and Person Accounts ...... } public static void runAfter( Boolean isInsert, Boolean isUpdate, Boolean isDelete, List<ContactWrapper> oldList, Map<Id, ContactWrapper> oldMap, List<ContactWrapper> newList, Map<Id, ContactWrapper> newMap) { // "after trigger" logic common to Contacts and Person Accounts ...... } }
Finally, the Contact triggers and Account triggers can simply call the above common class methods so that no logic is duplicated. See the following ContactBeforeTrigger.trigger and AccountBeforeTrigger.trigger as an example. Notice ContactWrappers is a utility class dealing with ContactWrapper objects.
// ContactBeforeTrigger.trigger trigger ContactBeforeTrigger on Contact (before insert, before update) { // Common logic for both Contact and Person Account records. Boolean isInsert = Trigger.isInsert; Boolean isUpdate = Trigger.isUpdate; Boolean isDelete = Trigger.isDelete; List<ContactWrapper> oldList = ContactWrappers.asList(Trigger.old); Map<Id, ContactWrapper> oldMap = ContactWrappers.asMap(Trigger.oldMap); List<ContactWrapper> newList = ContactWrappers.asList(Trigger.new); Map<Id, ContactWrapper> newMap = ContactWrappers.asMap(Trigger.newMap); ContactAndPersonAccountTriggerLogic.runBefore(isInsert, isUpdate, isDelete, oldList, oldMap, newList, newMap); // The following section is for logic that is specific for Contact. // Always think if the logic should be applied to Person Account before put the code here. ...... }
// AccountBeforeTrigger.trigger trigger AccountBeforeTrigger on Account (before insert, before update) { // Logic for Person Account if (PersonAccounts.isEnabledForOrg()) { List<ContactWrapper> oldList = ContactWrappers.asList(Trigger.old); Map<Id, ContactWrapper> oldMap = ContactWrappers.asMap(Trigger.oldMap); List<ContactWrapper> newList = ContactWrappers.asList(Trigger.new); Map<Id, ContactWrapper> newMap = ContactWrappers.asMap(Trigger.newMap); if ((oldList != null && oldList.size() > 0) || (newList != null && newList.size() > 0)) { ContactAndPersonAccountTriggerLogic.runBefore(Trigger.isInsert, Trigger.isUpdate, Trigger.isDelete, oldList, oldMap, newList, newMap); } } // The following section is for logic applied to normal Account (Business Account) ...... }
Great solution!
I have one question though. I develop a Managed Package, and in order to generate the package, all included Apex classes/triggers must pass the required percentage of test coverage. My development org doesn’t currently have Person Accounts enabled, so any triggers on the Account object can’t really be tested. Do you think using you’re method would help pass test coverage, since more of the code is shared with the testable Contact trigger?
You can probably improve your test coverage by doing such a refactoring but it still won’t cover the code for Person Account logic. To get better coverage, why not turn on “Person Account” feature for your development org?