LearnFrenzy
Sign in Take a free test

Apex Triggers · Lesson 28 of 41

Apex Trigger Scenarios with Solutions

7 min read Updated Nov 8, 2026 Saurabh Samir

Knowing the syntax is one thing; turning a business requirement into a correct, bulk-safe trigger is another. Here are four scenarios that show up again and again – in real projects and in interviews. Each one is short enough to read in a minute and handles 200 records as easily as one. (To keep each example self-contained, the logic sits in the trigger body; in a real org you'd put it in the handler from the handler lesson.)

In this lesson

  • Prevent duplicate Leads by email
  • Create a follow-up when an Opportunity is won
  • Block deleting Accounts with open deals
  • Copy the Account's address to new Contacts

Scenario 1: prevent duplicate Leads by email

Requirement: a Lead can't be created or changed to an email that another Lead already has – including duplicates inside the same import.

// LeadTrigger.trigger
trigger LeadTrigger on Lead (before insert, before update) {
    Map<String, Lead> incoming = new Map<String, Lead>();
    for (Lead l : Trigger.new) {
        if (String.isBlank(l.Email)) continue;
        Boolean changed = Trigger.isInsert || l.Email != Trigger.oldMap.get(l.Id).Email;
        if (!changed) continue;
        String key = l.Email.toLowerCase();
        if (incoming.containsKey(key)) {
            l.Email.addError('Duplicate email in this batch: ' + l.Email);   // duplicate inside the batch
        } else {
            incoming.put(key, l);
        }
    }
    if (incoming.isEmpty()) return;

    for (Lead existing : [SELECT Email FROM Lead WHERE Email IN :incoming.keySet() AND Id NOT IN :Trigger.new]) {
        incoming.get(existing.Email.toLowerCase()).Email.addError('A lead with this email already exists.');
    }
}
insert new Lead(LastName = 'Rao', Company = 'Globex', Email = 'neha@globex.example');

List<Lead> batch = new List<Lead>{
    new Lead(LastName = 'Rao', Company = 'Globex', Email = 'NEHA@globex.example'),   // exists already
    new Lead(LastName = 'Shah', Company = 'Initech', Email = 'dev@initech.example'),
    new Lead(LastName = 'Shah 2', Company = 'Initech', Email = 'dev@initech.example') // duplicate in batch
};
List<Database.SaveResult> res = Database.insert(batch, false);
for (Integer i = 0; i < res.size(); i++) {
    System.debug(batch[i].LastName + ': ' + (res[i].isSuccess() ? 'created' : res[i].getErrors()[0].getMessage()));
}

Debug log

Rao: A lead with this email already exists.
Shah: created
Shah 2: Duplicate email in this batch: dev@initech.example

Matching rules and duplicate rules can do this declaratively too; the Apex version is useful when the logic is more complex (normalised phone numbers, cross-object checks) or must apply to API loads without exceptions.

Scenario 2: Opportunity won → follow-up task and hot account

Requirement: when an Opportunity moves to Closed Won, create an onboarding Task for its owner and mark the Account as Hot – once, only on the change.

// OpportunityTrigger.trigger
trigger OpportunityTrigger on Opportunity (after update) {
    List<Task> tasks = new List<Task>();
    Set<Id> accountIds = new Set<Id>();

    for (Opportunity opp : Trigger.new) {
        Opportunity old = Trigger.oldMap.get(opp.Id);
        if (opp.IsWon && !old.IsWon) {                              // only on the transition
            tasks.add(new Task(Subject = 'Start onboarding: ' + opp.Name, WhatId = opp.Id,
                               OwnerId = opp.OwnerId, ActivityDate = Date.today().addDays(3)));
            if (opp.AccountId != null) accountIds.add(opp.AccountId);
        }
    }
    if (!tasks.isEmpty()) insert tasks;

    List<Account> hot = new List<Account>();
    for (Id accId : accountIds) hot.add(new Account(Id = accId, Rating = 'Hot'));
    if (!hot.isEmpty()) update hot;
}
Account acc = new Account(Name = 'Acme Corp', Rating = 'Cold');
insert acc;
Opportunity opp = new Opportunity(Name = 'Acme renewal', AccountId = acc.Id, StageName = 'Negotiation/Review', CloseDate = Date.today());
insert opp;

opp.StageName = 'Closed Won';
update opp;
opp.Description = 'edited after winning';
update opp;                                                     // no second task: not a transition

System.debug('Tasks: ' + [SELECT COUNT() FROM Task WHERE WhatId = :opp.Id]);
System.debug('Task: ' + [SELECT Subject FROM Task WHERE WhatId = :opp.Id].Subject);
System.debug('Account rating: ' + [SELECT Rating FROM Account WHERE Id = :acc.Id].Rating);

Debug log

Tasks: 1
Task: Start onboarding: Acme renewal
Account rating: Hot

Scenario 3: block deleting Accounts that have open deals

// AccountTrigger.trigger
trigger AccountTrigger on Account (before delete) {
    Set<Id> withOpenDeals = new Set<Id>();
    for (AggregateResult row : [SELECT AccountId FROM Opportunity
                                WHERE AccountId IN :Trigger.oldMap.keySet() AND IsClosed = false
                                GROUP BY AccountId]) {
        withOpenDeals.add((Id) row.get('AccountId'));
    }
    for (Account acc : Trigger.old) {
        if (withOpenDeals.contains(acc.Id)) {
            acc.addError('This account has open opportunities and cannot be deleted.');
        }
    }
}
Account busy = new Account(Name = 'Busy Ltd');
Account idle = new Account(Name = 'Idle Ltd');
insert new List<Account>{ busy, idle };
insert new Opportunity(Name = 'Open deal', AccountId = busy.Id, StageName = 'Prospecting', CloseDate = Date.today().addDays(30));

List<Database.DeleteResult> res = Database.delete(new List<Account>{ busy, idle }, false);
System.debug('Busy Ltd: ' + (res[0].isSuccess() ? 'deleted' : res[0].getErrors()[0].getMessage()));
System.debug('Idle Ltd: ' + (res[1].isSuccess() ? 'deleted' : res[1].getErrors()[0].getMessage()));

Debug log

Busy Ltd: This account has open opportunities and cannot be deleted.
Idle Ltd: deleted

In delete triggers there's no Trigger.new – you work with Trigger.old and Trigger.oldMap, and addError on those records blocks the delete.

Scenario 4: copy the Account's address to new Contacts

// ContactTrigger.trigger
trigger ContactTrigger on Contact (before insert) {
    Set<Id> accountIds = new Set<Id>();
    for (Contact c : Trigger.new) {
        if (c.AccountId != null && String.isBlank(c.MailingCity)) accountIds.add(c.AccountId);
    }
    if (accountIds.isEmpty()) return;

    Map<Id, Account> accounts = new Map<Id, Account>(
        [SELECT BillingStreet, BillingCity, BillingPostalCode, BillingCountry FROM Account WHERE Id IN :accountIds]);

    for (Contact c : Trigger.new) {
        Account acc = accounts.get(c.AccountId);
        if (acc == null || String.isNotBlank(c.MailingCity)) continue;
        c.MailingStreet = acc.BillingStreet;
        c.MailingCity = acc.BillingCity;
        c.MailingPostalCode = acc.BillingPostalCode;
        c.MailingCountry = acc.BillingCountry;
    }
}
Account acc = new Account(Name = 'Globex', BillingStreet = '12 MG Road', BillingCity = 'Pune', BillingPostalCode = '411001', BillingCountry = 'India');
insert acc;

List<Contact> contacts = new List<Contact>();
for (Integer i = 1; i <= 200; i++) contacts.add(new Contact(LastName = 'Employee ' + i, AccountId = acc.Id));
contacts.add(new Contact(LastName = 'Remote worker', AccountId = acc.Id, MailingCity = 'Goa'));
insert contacts;

System.debug('Copied: ' + [SELECT COUNT() FROM Contact WHERE MailingCity = 'Pune' AND MailingPostalCode = '411001']);
System.debug('Kept own address: ' + [SELECT MailingCity FROM Contact WHERE LastName = 'Remote worker'].MailingCity);
System.debug('Queries used: ' + Limits.getQueries());

Debug log

Copied: 200
Kept own address: Goa
Queries used: 3

201 contacts were inserted in one DML, which the platform delivered to the trigger in two chunks (200 + 1). The first chunk needed one query for all 200 accounts' addresses; the second – just the remote worker, who already had a city – returned early without querying. Three queries in total, two of them the script's own checks. The count stays tiny no matter how many contacts arrive.

Practice

Try it yourself

  1. When a Case is closed, if its Account has no other open Cases, set the Account's Description to 'No open cases'. Outline the trigger.

    Show answer

    After update on Case: collect AccountIds of cases that changed to closed (c.IsClosed && !old.IsClosed); one aggregate query for open case counts per account among those Ids; update accounts with zero open cases in one DML.

  2. Why does Scenario 2 compare opp.IsWon with old.IsWon instead of just checking opp.IsWon?

    Show answer

    Otherwise every later edit of a won opportunity would create another task. Reacting to the transition makes the trigger run its logic exactly once.

Quick check

Which context variables exist in a before delete trigger?

Trigger.old and Trigger.oldMap (no Trigger.new).

How do you catch duplicates inside the same batch?

Track keys in a Map or Set while looping over Trigger.new.

How many records does each trigger execution receive in a 201-record insert?

200, then 1 – two executions.

Summary

  • Validate and default fields in before triggers; touch other records in after triggers.
  • React to transitions by comparing with Trigger.oldMap.
  • Use Trigger.old with addError to block deletes.
  • Collect Ids, query once, save once – in every scenario.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Popular