Apex Object Oriented Programming – Hands-On

Introduction
Many times coming to the project we can see a lot of code that is very difficult to understand. Even at the moment we already know what the code does – where is it used? What if I modify the code, what will be the impact?
One of the ways to solve above problems (or at least minimize) is Object Oriented Programming.
You probably heard about the fact, that Apex is Object Oriented. But is your code TRULY Object Oriented?
As this article is a hands-on with two different activities – take some time for implementation on your own to see what’s your current most natural way of coding. One of the goals is to show you some structures you might understood, but never thought they might be useful.
Afterall we’ll have a short recap referring to some definitions you probably also heard before.
Before you start…
- Check if you know what’s going on within:
- All the activities (1, 2a and 2b) may take about 15-30mins each. Take care about having enough time to proceed.
- Be natural – write as you would really code in your project.
- After each activity you will see two ways to solve the problem. Take care about not scrolling too fast.
Activity 1
To Do
Write an Apex Trigger to forbid editing old Opportunities when running user has no specific permission for that action.
Do .addError(‘You cannot modify old records’) on every Trigger.new Opportunities under conditions:
Opportunity.CreatedDate < Datetime.now().addMonths(-1)
AND
!FeatureManagement.checkPermission('Modify_Old_Opportunities')
Probable solution
Knowing the Orgs we‘ve seen, we may expect code similar to following:
trigger OpportunityTrigger on Opportunity (before update) { OpportunityTriggerHelper.invalidateOld((List<Opportunity>) Trigger.new); }
public class OpportunityTriggerHelper { public static void invalidateOld(List<Opportunity> opportunities) { Datetime oneMonthAgo = Datetime.now().addMonths(-1); for (Opportunity opportunity : opportunities) { if (opportunity.CloseDate < oneMonthAgo && !FeatureManagement.checkPermission('Modify_Old_Opportunities')) { opportunity.addError('You cannot modify old Opportunities'); } } } }
How to make it different?
Imagine you want to create really on-purpose objects, that their understanding is very highlighted in your code.
trigger OpportunityTrigger on Opportunity (before update) { new OpportunityTriggerValidator((List<Opportunity>) Trigger.new).run(); }
public class OpportunityTriggerValidator { private final List<System.Type> VALIDATORS = new List<System.Type> { OldOpportunitiesValidator.class }; private List<Opportunity> newOpportunities; public OpportunityTriggerValidator(List<Opportunity> newOpportunities) { this.newOpportunities = newOpportunities; } public void run() { for (System.Type validator : VALIDATORS) { ((OpportunityValidatorInterface) validator.newInstance()).validate(this.newOpportunities); } } }
public interface OpportunityValidatorInterface { void validate(List<Opportunity> newOpportunities); }
public class OldOpportunitiesValidator implements OpportunityValidatorInterface { public static final Datetime ONE_MONTH_AGO = Datetime.now().addMonths(-1); public void validate(List<Opportunity> newOpportunities) { for (Opportunity newOpportunity : newOpportunities) { new OldOpportunityValidator(newOpportunity).validate(); } } }
public class OldOpportunityValidator { private Opportunity newOpportunity; public OldOpportunityValidator(Opportunity newOpportunity) { this.newOpportunity = newOpportunity; } public void validate() { if (opportunityIsTooOld() && userHasNoPermissionToModifyOld()) { this.newOpportunity.addError('You cannot modify old Opportunities'); } } private Boolean opportunityIsTooOld() { return this.newOpportunity.CloseDate < OldOpportunitiesValidator.ONE_MONTH_AGO; } private Boolean userHasNoPermissionToModifyOld () { return !FeatureManagement.checkPermission('Modify_Old_Opportunities'); } }
Conclusion
This is not a prove, but the example of the fact, that most of the code we type might be coded in “structural-object-oriented” and “truly-object-oriented” way.
Knowing that – OOP is primarily about willingness for it’s application and noticing spots where OOP is worth applying. If we do not consider budget (at the moment, because in the future it will probably benefit even in this field), but code readability and readiness for extensions – it’s worth it nearly always.
Category | “structural-object-oriented” | “truly-object-oriented” |
---|---|---|
How to quick-check | Mostly uses static methods | No static if not necessary (or obviously more efficient, example: Singleton Pattern) |
Backbone | Uses “class” as a container for methods that will execute in structural way, one by one, passing all necessary information as arguments | Uses “class” with “new” keyword giving it a meaning of its existence called “object” – sets the properties to the object and allows to execute methods that the object know and can do |
Lots of code written “structural-object-oriented” way will be:
- less effective
- less readable
- less maintainable and understandable
- less reusable
(Disclaimer: Giving no proof within this article, because of willingness to stay as close as could be to hands-ons – above will be most valid for complex classes that would be madness with full-static approach. Treat this as author own assessment)
Afterall you may say – it’s an act of sheer folly to create so many classes for such simple validation! Surething! Always understand not only the requirement, but also the future. When you know – this will be the only 5 lines of code for following 10 years for this functionality – go with static. But imagine the pleasure of the developer coming here after you, requested to add one more rule about validating Opportunities, just needing to implement OpportunityValidatorInterface one more time (and add the class name to the list in OpportunityTriggerValidator). You want to be the one!
(Extra: After implementing new rule – regression tests not required, because old functionality have not been impacted. In static solution you would need to modify the algorithm, not just extend the functionality)
Activity 2
To Do
DO NOT SCROLL TO b) BEFORE IMPLEMENTING
Preparation
- Create AdjustedAmount__c (Currency 16,2) Custom Field on Opportunity
- Create CustomerLevel__c (Picklist: Gold Customer/Standard Customer/Problematic Customer) Custom Field on Opportunity
a)
Write an Apex Trigger to fill Opportunity.AdjustedAmount__c field with calculated value.
On before insert & before update fill Opportunity.AdjustedAmount__c based on algorithm:
- 50% of Opportunity.Amount when Opportunity.Account.CustomerLevel__c = 'Gold Customer;
- (100 – Count of All Opportunities related to the Account of the Opportunity having IsClosed=false)% of Opportunity.Amount when Opportunity.Account.CustomerLevel__c = 'Standard Customer'
- (100 + Count of All Cases related to the Account of the Opportunity having IsClosed=false)%
of Opportunity.Amount when Opportunity.Account.CustomerLevel__c = 'Problematic Customer'
AFTER YOU ALREADY DID THE a) PART
b)
Imagine your client told you that they will have 'Bronze', 'Silver' and 'Platinum' Customer Levels, but they are still being defined by the business. Would this change your approach?
If your first approach was too structural – you can try now with truly OOP code.
Probable solution
Once again – knowing the Orgs we‘ve seen, we may expect code similar to following:
trigger OpportunityTrigger on Opportunity (before insert, before update) { OpportunityTriggerHelper.calculateAdjustedAmounts((List<Opportunity>) Trigger.new); }
public class OpportunityTriggerHelper { public static void calculateAdjustedAmounts(List<Opportunity> opportunities) { Set<Id> accountIds = new Set<Id>(); for (Opportunity opportunity : opportunities) { accountIds.add(opportunity.AccountId); } Map<Id, Account> accountMap = new Map<Id, Account>([SELECT Id, CustomerLevel__c, (SELECT Id FROM Opportunities WHERE IsClosed = false), (SELECT Id FROM Cases WHERE IsClosed = false) FROM Account WHERE Id IN :accountIds]); for (Opportunity opportunity : opportunities) { if (accountMap.containsKey(opportunity.AccountId)) { Account acc = accountMap.get(opportunity.AccountId); Decimal adjustedAmount = 0; if (acc.CustomerLevel__c == 'Gold Customer') { adjustedAmount = opportunity.Amount * 0.5; } else if (acc.CustomerLevel__c == 'Standard Customer') { Integer openOppCount = acc.Opportunities.size(); adjustedAmount = opportunity.Amount * ((100 - openOppCount) / 100.0); } else if (acc.CustomerLevel__c == 'Problematic Customer') { Integer openCaseCount = acc.Cases.size(); adjustedAmount = opportunity.Amount * ((100 + openCaseCount) / 100.0); } opportunity.AdjustedAmount__c = adjustedAmount; } } } }
How to make it different?
Imagine you are requested to make this code as prepared for new requirements as it could be. To make following releases as most secure as we can – for the newly created code not to impact already existing while created.
trigger OpportunityTrigger on Opportunity (before insert, before update) { new OpportunityAdjustedAmountsFiller((List<Opportunity>) Trigger.new).fill(); }
public class OpportunityAdjustedAmountsFiller { private static final Map<String, System.Type> ALGORITHM_BY_CUSTOMER_LEVEL = new Map<String, System.Type>{ 'Gold Customer' => OpportunityAdjAmountAlg_Gold.class, 'Standard Customer' => OpportunityAdjAmountAlg_Standard.class, 'Problematic Customer' => OpportunityAdjAmountAlg_Problematic.class }; private List<Opportunity> newOpportunities; private Map<Id, Account> accountsWithOpportunitiesAndCases; public OpportunityAdjustedAmountsFiller(List<Opportunity> newOpportunities) { this.newOpportunities = newOpportunities; this.accountsWithOpportunitiesAndCases = new Map<Id, Account>([ SELECT Id, CustomerLevel__c, (SELECT Id FROM Opportunities WHERE IsClosed = false), (SELECT Id FROM Cases WHERE IsClosed = false) FROM Account WHERE Id IN :Utils.getSetOfIds(this.newOpportunities, Opportunity.AccountId) ]); } public void fill() { for (Opportunity newOpportunity : this.newOpportunities) { Account opportunityAccount = this.accountsWithOpportunitiesAndCases.get(newOpportunity.AccountId); ((OpportunityAdjAmountAlgorithm) ALGORITHM_BY_CUSTOMER_LEVEL.get(opportunityAccount.CustomerLevel__c).newInstance()) .setAccount(opportunityAccount) .fill(newOpportunity); } } }
public abstract class OpportunityAdjAmountAlgorithm { protected Account accountWithOpportunitiesAndCases; protected abstract Decimal getPercentageAdjustment(); public OpportunityAdjAmountAlgorithm setAccount(Account accountWithOpportunitiesAndCases) { this.accountWithOpportunitiesAndCases = accountWithOpportunitiesAndCases; return this; } public void fill(Opportunity opportunity) { opportunity.AdjustedAmount__c = opportunity.Amount * getPercentageAdjustment(); } }
public class OpportunityAdjAmountAlg_Gold extends OpportunityAdjAmountAlgorithm { protected override Decimal getPercentageAdjustment() { return 0.5; } }
public class OpportunityAdjAmountAlg_Standard extends OpportunityAdjAmountAlgorithm { protected override Decimal getPercentageAdjustment() { return (100 - this.accountWithOpportunitiesAndCases.Opportunities.size()) / 100.0; } }
public class OpportunityAdjAmountAlg_Problematic extends OpportunityAdjAmountAlgorithm { protected override Decimal getPercentageAdjustment() { return (100 + this.accountWithOpportunitiesAndCases.Cases.size()) / 100.0; } }
public class Utils { public static Set<Id> getSetOfIds(List<SObject> sObjects, SObjectField sourceField) { Set<Id> ids = new Set<Id>(); for (SObject currentSObject : sObjects) { ids.add(Id.valueOf(String.valueOf(currentSObject.get(sourceField)))); } return ids; } }
Conclusion
In this scenario adding new algorithm won’t impact previous implementation. This activity will require only extending OpportunityAdjAmountAlgorithm one more time and adding a class to the map in OpportunityAdjustedAmountsFiller.
This might also be confusing in such simple scenario, but when dealing with complex requirement – such logic isolation helps (or sometimes even allows!) adjust the functionality to the new requirements.
What are the isolations here?
- A silos for data sources for all fillers – obtained once only in constructor – triggering filler in different context will just require to implement one more constructor
- A silos for algorithm selection – just “get the proper algorithm for this Customer Level value” – when new requirement comes to implement new Customer Level – we just add one more algorithm to be selected – this logic for algorithm selection will always say “get the proper algorithm for this Customer Level value” (oh, till the time that the customer says to change this logic, but this will be done within algorithm selection silos and most important INTENTIONAL, not by the side effect for other change)
- A silos for an algorithm – which is a polymorphic silos (always the same core) that receives parameters and behaves the way it was instantiated to behave (by selecting proper type for calling “newInstance()” on it).
Extra: Have you thought about triggering upon Account Customer Level changed is also necessary?
Imagine faced this after the above implementation. What happens?
- “structural-object-oriented”:
- Copy & Paste the logic, prepare data differently – redundancy
- Separate the logic to another service and call from two helpers – regression required
- “truly-object-oriented”:
- Add one more constructor to OpportunityAdjustedAmountsFiller
Choose the right way yourself!
Recap
Conclusions sound like you heard it somewhere?
What we have above is pretty similar to Single responsibility & Open/closed principles proposed by Robert C. Martin. Yes, it’s not only the theoretical question on an interview – now you can see the real advantage in following (in my opinion) often violated principles.
Always remember to write a code that:
- Is efficient
- Is easy to understand
- Self explains
- Is extendable
- Will be easy to understand
- Will be extendable