What I Learned From Writing My First Trigger

By

I’m a Salesforce Admin. I’m not a developer. I can build you a great Salesforce solution, but I use clicks, not code. Not too long ago, a request to prevent deletion of certain opportunities would have been a nonstarter because we were faced with two main challenges: we don’t have money in the budget for a consultant, and Process Builder can’t fire on delete of a record. So when my boss asked if we could prevent users from deleting opportunities in the stage Receivable or above (because they are already on our accountant’s books as accounts receivable), my initial thought was that there was no way we could do it. 

But, I’m always willing to learn new things. So upon further reflection, I decided I might be able to learn how to write a simple trigger for this very specific use case. And I’m proud to say that I did! As you’re reading this, that trigger is in our production environment keeping our data clean. To help you accomplish your company’s goals, I want to share with you the things I learned in the process of writing this new trigger and beginning to learn about the code side of Salesforce.

New to coding?

If you’re completely new to coding in Apex, check out some Trailhead modules to get you started. You can learn to write your first trigger or learn Apex basics, among many other skills. Don’t forget that you must develop in a sandbox environment—you can’t write new code directly in production, even if you (foolishly) wanted to. And I’m going to assume that if you’re new to coding, you don’t have a coding environment you usually work in. That’s OK—Salesforce provides! The Developer Console is an integrated development environment with all the features you’ll need in the short-run. The best part is that it’s already connected to your org. You can learn more about the dev console on Trailhead, too.

Don’t be afraid to ask for help

Have I mentioned that I’m new to this? After puzzling out approximately what I needed, thanks to Trailhead and Google, I had figured out a very simple trigger to prevent deletion.

I was ready for some external validation but I still needed a little help. I realized that I needed to still allow a sysadmin to delete a Receivable or we’d never be able to fix a mistaken record. The Salesforce Ohana came to the rescue! I posted to Salesforce StackExchange (affectionately known as SFSE) after determining it to be the best forum to ask my code-specific question. The Salesforce community is truly a marvel—generous with time and knowledge. In no time I had the answer I needed and soon after that my trigger was ready. Next up, I tackled unit tests for code coverage.

Testing is more than half the battle

Surprisingly, I spent significantly more time writing my tests than it took me to learn and write the trigger itself. My trigger is just 13 lines of code. But my test finishes on line 196! Doing testing correctly requires inserting data to test against and figuring out all the conditions you need to try. I turned again to SFSE for some advice. Again, Salesforce folks were generous with their knowledge and their time. (I’d like to especially thank Thomas Taylor, who saw my SFSE post, but also wrote to me directly with some interesting advice based on things he’d just learned days before at the PhillyForce conference.) Next up, code review.

Fresh eyes see new things

I still had one more chance to rely on the help of the Salesforce Ohana. I happened to mention to Bhanudas Tanaka that I’d written my first trigger. Bahnu offered to do a code review with me and I was glad for the chance to get some one-on-one live coaching about my code. Online communities are great, but there is no substitute for actually engaging in a realtime dialog!

Here’s the state of my trigger when we met:

trigger OpportunityBeforeDelete on Opportunity (before delete) {

    

    String profileName = [SELECT Name FROM Profile WHERE Id = :UserInfo.getProfileId()].Name;

    

    for(Opportunity opp: trigger.old) {

        if (opp.Receivable__c == true && profileName != 'System Administrator'){

            opp.adderror('Receivable opportunities should not be deleted. See wiki: https://sites.google.com/a/sparkprogram.org/spark-interconnected-data-systems-wiki/finance/receivable-opportunities Receivable opportunities can only be deleted by a system administrator.');

        } 

    }

}

The first thing Bhanu noticed was that my trigger was not set up to stick with the One Trigger Per Object best practice. The basic principle with that best practice being: If you stick to just one trigger (and/or Process) per object, it’s much easier to see all the automations that could fire. Fresh eyes on my trigger immediately noticed that it was written as a trigger specifically for before delete, leaving out all the other times a trigger on Opportunity might fire like before insert, after insert, before update, after update, after delete, after undelete. Glad we caught that!

The other thing Bhanu pointed out was that my trigger is firing and actually doing the work. As a general rule, it’s considered better to have the trigger stick to doing just one thing: firing at the right time. Then the trigger will hand off the required business logic and actions to an Apex class (known as a “helper class.”) That’s a little different than how we usually work on the declarative side. Process Builder can do some of the business logic, so you don’t have to always handoff to a Flow. In this case, however, we decided that it wouldn’t make sense to move the logic into a helper class because there’s so little of it. Writing that extra class would mean also having to write a test for the class, thus doubling from two things to deploy (a trigger and its test) to four (trigger, test, Apex class, test for Apex class). However, it is good to note that I made a choice to do it this way intentionally.

After our meeting, I rewrote my trigger to:

trigger OpportunityTrigger on Opportunity (before delete) {

    

    //Trigger files ONLY BEFORE DELETE at the moment. But this logic will apply if additional contexts are added.

    if (Trigger.isDelete) {

        String profileName = [SELECT Name FROM Profile WHERE Id = :UserInfo.getProfileId()].Name;

        

        for(Opportunity opp: trigger.old) {

            if (opp.Receivable__c == true && profileName != 'System Administrator'){

                opp.adderror('Receivable opportunities should not be deleted. See wiki: https://sites.google.com/a/sparkprogram.org/spark-interconnected-data-systems-wiki/finance/receivable-opportunities Receivable opportunities can only be deleted by a system administrator.');

            } 

        }

    }

}

 

Notice that I added a comment to my future self. I’ve changed the trigger name to indicate that it’s going to be The One Trigger on Opportunity. Lastly, even though it’s not technically needed so long as the trigger fires only on delete, I’ve added the logic that would be required in other contexts.

Are you ready to try it?

If you’ve got a business requirement that you can’t meet with declarative tools, you can see that it might not be that hard to inch across the line into programmatic automation. When you do, let me know, and share what you’ve learned!

 

In case you’re curious I’ve included my test class below:

@isTest

public class TestOpportunityTrigger {

    //test class for trigger OpportunityBeforeDelete  

    

    @testSetup

    public static void setup() {

        //insert a User that is not sysadmin

        List <User> usrs = new List<User>();

        Profile p = [SELECT Id FROM Profile WHERE Name='Spark Exec'];

        Profile p2 = [SELECT Id FROM Profile WHERE Name='System Administrator'];

        

        User standardu = new User(Alias = 'standt', Email='standarduser@sparkprogram.org',

                                  EmailEncodingKey='UTF-8', LastName='Testing', LanguageLocaleKey='en_US',

                                  LocaleSidKey='en_US', ProfileId = p.Id,

                                  TimeZoneSidKey='America/Los_Angeles', UserName='standarduser@sparkprogram.org');

        usrs.add(standardu);

        

        //insert a User that is sysadmin

        User sysadminuser = new User(Alias = 'sysadmin', Email='sysadminuser@sparkprogram.org',

                                     EmailEncodingKey='UTF-8', LastName='Testing', LanguageLocaleKey='en_US',

                                     LocaleSidKey='en_US', ProfileId = p2.Id,

                                     TimeZoneSidKey='America/Los_Angeles', UserName='sysadminuser@sparkprogram.org');

        usrs.add(sysadminuser);

        insert usrs;

        

        //Insert an account

        Account newaccount = new Account (

            Name = 'Account');

        insert newaccount;

        

        //Insert three opportunities that are Receivable and three that are not

        List<Opportunity> oppos = new list<Opportunity>();    

        for (integer i = 0; i<3; i++){

            Opportunity rcblopp = new Opportunity (

                Name = ('Test to be Receivable' + i),

                AccountId = newaccount.Id,

                Amount = (10000 + i),

                StageName = 'Receivable',

                Receivable__c = true,

                CloseDate = system.today()

            );

            oppos.add(rcblopp);

        }

        

        for (integer i = 0; i<3; i++){

            Opportunity regopp = new Opportunity (

                Name = ('Test to not be Receivable' + i),

                AccountId = newaccount.Id,

                Amount = (35.90 + i),

                StageName = 'Fully Paid',

                Receivable__c = false,

                CloseDate = system.today()

            );

            oppos.add(regopp);

        }

        insert oppos;

    }

    

    //non-sysadmin can't delete single receivable

    @isTest

    public static void myTestMethod1(){

        User standardu = [SELECT Id from User WHERE UserName = 'standarduser@sparkprogram.org'];

        system.runAs(standardu){

            

            // Perform test

            Test.startTest();

            Opportunity rcblopp = [SELECT Id from Opportunity WHERE Receivable__c = true LIMIT 1];

            try {

                delete rcblopp;

            }

            catch (DMLException e)

            {

                // not expected - could assert the message here

                system.debug('Should have gotten the error message from the trigger.');

            }

            

            Database.DeleteResult result = Database.delete(rcblopp, false);

            Test.stopTest();

            System.assert(!result.isSuccess());

            System.assert(result.getErrors().size() > 0);

            System.assertEquals('Receivable opportunities should not be deleted. See wiki: https://sites.google.com/a/sparkprogram.org/spark-interconnected-data-systems-wiki/finance/receivable-opportunities Receivable opportunities can only be deleted by a system administrator.',

                                result.getErrors()[0].getMessage());

            

        }

        

    }

    

    //non-sysadmin can delete single non-receivable

    @isTest

    public static void myTestMethod2(){

        User standardu = [SELECT Id from User WHERE UserName = 'standarduser@sparkprogram.org'];

        system.runAs(standardu){

            

            // Perform test

            Test.startTest();

            Opportunity rcblopp = [SELECT Id from Opportunity WHERE Receivable__c = false LIMIT 1];

            try {

                // expected

                delete rcblopp;

                system.debug('Deletion should have gone through.');

            }

            catch (DMLException e)

            {

                // not expected

                system.debug('Deletion should have gone through.');

            }

            

            Database.DeleteResult result = Database.delete(rcblopp, false);

            Test.stopTest();

            System.assert(!result.isSuccess());

            

        }

        

    }

    

    //Deletion of a single receivable works for a sysadmin

    @isTest

    public static void myTestMethod3(){

        User sysadmin = [SELECT Id from User WHERE UserName = 'sysadminuser@sparkprogram.org'];

        system.runAs(sysadmin){

            

            // Perform test

            Test.startTest();

            Opportunity rcblopp = [SELECT Id from Opportunity WHERE Receivable__c = true LIMIT 1];

            try {

                // expected

                delete rcblopp;

                system.debug('Deletion should have gone through.');

            }

            catch (DMLException e)

            {

                // not expected

                system.debug('Deletion should have gone through.');

            }

            

            Database.DeleteResult result = Database.delete(rcblopp, false);

            Test.stopTest();

            System.assert(!result.isSuccess());

            

        }

    }

    

    //non-sysadmin can't delete multiple receivables

    @isTest

    public static void myTestMethod4(){

        User standardu = [SELECT Id from User WHERE UserName = 'standarduser@sparkprogram.org'];

        system.runAs(standardu){

            

            // Perform test

            Test.startTest();

            List<Opportunity> rcblopps = [SELECT Id from Opportunity WHERE Receivable__c = true];

            try {

                delete rcblopps;

            }

            catch (DMLException e)

            {

                // not expected - could assert the message here

                system.debug('Should have gotten the error message from the trigger.');

            }

            

            Database.DeleteResult[] results = Database.delete(rcblopps, false);

            Test.stopTest();

            System.assert(!results[0].isSuccess());

            System.assert(results[0].getErrors().size() > 0);

            System.assertEquals('Receivable opportunities should not be deleted. See wiki: https://sites.google.com/a/sparkprogram.org/spark-interconnected-data-systems-wiki/finance/receivable-opportunities Receivable opportunities can only be deleted by a system administrator.',

                                results[0].getErrors()[0].getMessage());

            

        }

        

    }

    

    //sysadmin can delete multiple receivables

    @isTest

    public static void myTestMethod5(){

        User sysadmin = [SELECT Id from User WHERE UserName = 'sysadminuser@sparkprogram.org'];

        system.runAs(sysadmin){

            

            // Perform test

            Test.startTest();

            List<Opportunity> rcblopps = [SELECT Id from Opportunity WHERE Receivable__c = true];

            try {

                delete rcblopps;

            }

            catch (DMLException e)

            {

                // not expected - could assert the message here

                system.debug('Should have gotten the error message from the trigger.');

            }

            

            Database.DeleteResult[] results = Database.delete(rcblopps, false);

            Test.stopTest();

            System.assert(!results[0].isSuccess());           

        }

        

    }

}

Build a Simple Screen Flow 🧐

I’m a Salesforce Admin. I’m not a developer. I can build you a great Salesforce solution, but I use clicks, not code. Not too long ago, a request to prevent deletion of certain opportunities would have been a nonstarter because we were faced with two main challenges: we don’t have money in the budget for […]

READ MORE

Salesforce Admins World-Wide Are Innovating with Flow

I’m a Salesforce Admin. I’m not a developer. I can build you a great Salesforce solution, but I use clicks, not code. Not too long ago, a request to prevent deletion of certain opportunities would have been a nonstarter because we were faced with two main challenges: we don’t have money in the budget for […]

READ MORE

Use Einstein Next Best Action with Flow

I’m a Salesforce Admin. I’m not a developer. I can build you a great Salesforce solution, but I use clicks, not code. Not too long ago, a request to prevent deletion of certain opportunities would have been a nonstarter because we were faced with two main challenges: we don’t have money in the budget for […]

READ MORE

Have an Idea for a Story?

We are all about the community and sharing ideas.
Do you have an interesting idea or useful tip that you want to share?

SHARE YOUR IDEA