How You need to update a related child record whenever a parent record’s status changes, but only if the status is “Closed Won.” How would you design this in Apex?

Updating a related child record when a parent record’s Status changes to “Closed Won” is a very common real-world Salesforce requirement. This scenario is typically seen with standard objects like Opportunity → OpportunityLineItem, Account → Contact, or custom objects like Project__c → Task__c.
In this detailed explanation, we will:
- Understand the business requirement
- Discuss design considerations
- Explain trigger architecture best practices
- Write bulkified Apex code
- Add a handler class
- Cover test class design
- Discuss edge cases and governor limits
- Provide optimization tips
🔹 Business Requirement
Whenever a parent record’s Status changes to “Closed Won”, update related child records — but only when:
- The Status field changes
- The new value is "Closed Won"
- The update should happen only once
- The solution must be bulk-safe
Example Scenario:
Parent: Opportunity
Child: Project__c (custom object)
Relationship: Project__c.Opportunity__c (Lookup to Opportunity)
Requirement:
When Opportunity StageName = “Closed Won” →
Update all related Project__c records → set Project_Status__c = 'Active'
🔹 Design Approach
The correct design pattern in Apex should follow:
- ✅ Use an After Update Trigger
- ✅ Check old vs new value
- ✅ Bulkify logic
- ✅ Use handler class (separation of concerns)
- ✅ Avoid SOQL in loops
- ✅ Avoid DML in loops
🔹 Why After Update?
We use after update trigger because:
- Parent record must already be committed
- We are updating related child records
- Relationship fields are available
🔹 Trigger Design Architecture
We follow this structure:
Trigger → Handler Class → Logic Method
JavaScriptThis ensures:
- Clean code
- Reusability
- Easy testing
- Maintainability
🔹 Step 1: Trigger Code
trigger OpportunityTrigger on Opportunity (after update) {
if(Trigger.isAfter && Trigger.isUpdate) {
OpportunityTriggerHandler.handleClosedWon(Trigger.new, Trigger.oldMap);
}
}
JavaScriptExplanation:
Trigger.new→ New version of recordsTrigger.oldMap→ Old version (to compare status change)
🔹 Step 2: Handler Class (Bulkified)
public class OpportunityTriggerHandler {
public static void handleClosedWon(List<Opportunity> newList, Map<Id, Opportunity> oldMap) {
Set<Id> closedWonOppIds = new Set<Id>();
// Step 1: Identify Opportunities whose status changed to Closed Won
for(Opportunity opp : newList) {
Opportunity oldOpp = oldMap.get(opp.Id);
if(opp.StageName == 'Closed Won' &&
oldOpp.StageName != 'Closed Won') {
closedWonOppIds.add(opp.Id);
}
}
if(closedWonOppIds.isEmpty()) {
return;
}
// Step 2: Query related child records
List<Project__c> projectsToUpdate = [
SELECT Id, Project_Status__c, Opportunity__c
FROM Project__c
WHERE Opportunity__c IN :closedWonOppIds
];
// Step 3: Update child records
for(Project__c proj : projectsToUpdate) {
proj.Project_Status__c = 'Active';
}
if(!projectsToUpdate.isEmpty()) {
update projectsToUpdate;
}
}
}
JavaScript🔹 Explanation of Code
✔ Step 1: Detect Status Change
if(opp.StageName == 'Closed Won' &&
oldOpp.StageName != 'Closed Won')
JavaScriptThis ensures:
- Only run when stage changes
- Avoid unnecessary updates
- Prevent recursion
✔ Step 2: Use Set for IDs
Using Set<Id>:
- Avoid duplicates
- Efficient SOQL query
- Bulk-safe
✔ Step 3: One SOQL Query
WHERE Opportunity__c IN :closedWonOppIds
JavaScriptThis prevents SOQL inside loop.
✔ Step 4: One DML Statement
We update all records at once:
update projectsToUpdate;
JavaScriptThis avoids hitting DML governor limit (150 per transaction).
🔹 Governor Limits Covered
| Limit | Safe? |
|---|---|
| SOQL Queries | Yes (1 query) |
| DML Statements | Yes (1 update) |
| CPU Time | Optimized |
| Heap Size | Minimal |
This solution works even if:
- 200 Opportunities updated at once
- Each has 50 child records
🔹 Alternative Example (Standard Objects)
If requirement is:
When Opportunity Closed Won → Update all related Quotes
List<Quote> quotes = [
SELECT Id, Status
FROM Quote
WHERE OpportunityId IN :closedWonOppIds
];
for(Quote q : quotes) {
q.Status = 'Approved';
}
update quotes;
JavaScriptSame pattern applies.
🔹 Adding Recursion Control (Advanced Design)
If child update triggers another update on parent, we prevent infinite loop using static variable.
public class OpportunityTriggerHandler {
public static Boolean isRunning = false;
public static void handleClosedWon(List<Opportunity> newList, Map<Id, Opportunity> oldMap) {
if(isRunning) return;
isRunning = true;
// logic here
isRunning = false;
}
}
JavaScript🔹 Test Class (Very Important)
Minimum 75% coverage required.
@isTest
public class OpportunityTriggerTest {
@isTest
static void testClosedWonUpdate() {
// Step 1: Create Opportunity
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
CloseDate = Date.today()
);
insert opp;
// Step 2: Create Child Record
Project__c proj = new Project__c(
Name = 'Test Project',
Opportunity__c = opp.Id,
Project_Status__c = 'Pending'
);
insert proj;
// Step 3: Update Opportunity to Closed Won
opp.StageName = 'Closed Won';
update opp;
// Step 4: Verify child updated
Project__c updatedProj = [
SELECT Project_Status__c
FROM Project__c
WHERE Id = :proj.Id
];
System.assertEquals('Active', updatedProj.Project_Status__c);
}
}
JavaScript🔹 Why This Test is Good
- Covers trigger
- Covers handler
- Tests status change logic
- Verifies actual update
- Uses real data
🔹 Edge Cases to Handle
1️⃣ No Child Records
Handled by:
if(projectsToUpdate.isEmpty()) return;
JavaScript2️⃣Bulk Update of 200 Opportunities
Handled by:
- Using Set
- Single SOQL
- Single DML
3️⃣ Stage Changed From Closed Won → Closed Lost
Not triggered because:
oldOpp.StageName != 'Closed Won'
JavaScript4️⃣ Stage Already Closed Won (No Change)
Not triggered.
🔹 Enterprise-Level Best Practice (Trigger Framework)
In large projects, use:
- One trigger per object
- Handler classes
- Service classes
- Unit tests for each scenario
Example structure:
OpportunityTrigger
OpportunityHandler
OpportunityService
OpportunityTest
JavaScript🔹 When to Use Future or Queueable?
If:
- Child records > 10,000
- Heavy logic
- Integration callouts
Then use:
@future
JavaScriptor
Queueable Apex
JavaScriptBut for normal child update → synchronous is fine.
🔹 Flow vs Apex?
You could use:
- Record Triggered Flow
- Process Builder (not recommended now)
But Apex is preferred when:
- Complex logic
- Performance critical
- Need bulk control
- Enterprise architecture
🔹 Final Production-Ready Version (Optimized)
trigger OpportunityTrigger on Opportunity (after update) {
if(Trigger.isAfter && Trigger.isUpdate) {
OpportunityTriggerHandler.handleClosedWon(Trigger.new, Trigger.oldMap);
}
}
JavaScriptpublic with sharing class OpportunityTriggerHandler {
public static void handleClosedWon(List<Opportunity> newList, Map<Id, Opportunity> oldMap) {
Set<Id> oppIds = new Set<Id>();
for(Opportunity opp : newList) {
if(opp.StageName == 'Closed Won' &&
oldMap.get(opp.Id).StageName != 'Closed Won') {
oppIds.add(opp.Id);
}
}
if(oppIds.isEmpty()) return;
List<Project__c> projectList = [
SELECT Id, Project_Status__c
FROM Project__c
WHERE Opportunity__c IN :oppIds
];
for(Project__c p : projectList) {
p.Project_Status__c = 'Active';
}
if(!projectList.isEmpty()) {
update projectList;
}
}
}
JavaScript🔹 Key Interview Points
If asked in interview, mention:
- Use after update trigger
- Compare oldMap and new values
- Bulkify using Set
- Single SOQL and DML
- Use handler class
- Add test class
- Handle recursion
- Consider async if large data
🔹 Summary
To update related child records when parent status becomes “Closed Won”:
- Use after update trigger
- Compare old and new values
- Store parent IDs in Set
- Query children once
- Update in bulk
- Avoid SOQL/DML inside loop
- Add proper test class
- Consider recursion control
- Follow best practice architecture
Tags:
Related Posts

How to Automatically create a follow-up Task when a Lead is converted

Business wants to automatically create an Invoice record whenever an Order record’s status becomes “Completed,” but only if a related payment record exists. How would you achieve this?
