Apex Triggers · Lesson 28 of 41
Apex Trigger Scenarios with Solutions
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()));
}
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);
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()));
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());
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
When a Case is closed, if its Account has no other open Cases, set the Account's
Descriptionto '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.Why does Scenario 2 compare
opp.IsWonwithold.IsWoninstead of just checkingopp.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.