When an Opportunity becomes Closed Won, Salesforce should create a related Order asynchronously.
Instead of creating the Order directly inside the Opportunity trigger, the trigger publishes a Platform Event. The Platform Event subscriber then performs the Order creation using a clean layered architecture.
Architecture Flow
Opportunity Trigger / Flow
Metadata Required
Create a Platform Event named:
Add these fields:
Opportunity_Id__c Text(18)
Account_Id__c Text(18)
Amount__c Currency
Close_Date__c Date
Event_Source__c Text(50)
Create a custom lookup field on the standard Order object:
Type: Lookup(Opportunity)
This field helps prevent duplicate Order creation for the same Opportunity.
Opportunity Trigger
trigger OpportunityTrigger on Opportunity (after update) {
if (Trigger.isAfter && Trigger.isUpdate) {
OrderRequestPublisher.publishForClosedWonOpportunities(
Trigger.new,
Trigger.oldMap
);
}
}
The trigger is intentionally thin. It does not contain business logic, SOQL, DML, callouts, or heavy processing.
Apex Publisher Class
public with sharing class OrderRequestPublisher {
public static void publishForClosedWonOpportunities(
List<Opportunity> newOpportunities,
Map<Id, Opportunity> oldOpportunityMap
) {
List<Order_Request__e> eventsToPublish = new List<Order_Request__e>();
for (Opportunity opp : newOpportunities) {
Opportunity oldOpp = oldOpportunityMap.get(opp.Id);
Boolean becameClosedWon =
opp.StageName == 'Closed Won' &&
oldOpp.StageName != 'Closed Won';
if (becameClosedWon && opp.AccountId != null) {
eventsToPublish.add(new Order_Request__e(
Opportunity_Id__c = String.valueOf(opp.Id),
Account_Id__c = String.valueOf(opp.AccountId),
Amount__c = opp.Amount,
Close_Date__c = opp.CloseDate,
Event_Source__c = 'OpportunityTrigger'
));
}
}
if (!eventsToPublish.isEmpty()) {
EventBus.publish(eventsToPublish);
}
}
}
This class is responsible only for publishing the business event. It does not create Orders directly. That makes the Opportunity transaction lightweight and decoupled from downstream processing.
trigger OrderRequestEventTrigger on Order_Request__e (after insert) {
OrderApi.createOrdersFromEvents(Trigger.new);
}
This trigger subscribes to the Platform Event. Platform Event triggers run asynchronously, so the Order creation process is decoupled from the Opportunity update transaction.
public with sharing class OrderApi {
public static void createOrdersFromEvents(List<Order_Request__e> events) {
if (events == null || events.isEmpty()) {
return;
}
OrderService service = new OrderService(
new OrderRepository(),
new ApplicationUnitOfWork()
);
service.createOrders(events);
}
}
The API class acts as a facade or application entry point. It hides the internal implementation and delegates to the service layer.
The service class contains business logic. It decides which Opportunities are valid, whether an Order already exists, what Order data should be created, and when DML should be committed.
public with sharing class OrderService {
private OrderRepository orderRepository;
private ApplicationUnitOfWork unitOfWork;
public OrderService(
OrderRepository orderRepository,
ApplicationUnitOfWork unitOfWork
) {
this.orderRepository = orderRepository;
this.unitOfWork = unitOfWork;
}
public void createOrders(List<Order_Request__e> events) {
Set<Id> opportunityIds = extractOpportunityIds(events);
if (opportunityIds.isEmpty()) {
return;
}
Map<Id, Opportunity> opportunitiesById =
orderRepository.getOpportunitiesById(opportunityIds);
Map<Id, Order> existingOrdersByOpportunityId =
orderRepository.getExistingOrdersByOpportunityId(opportunityIds);
for (Id opportunityId : opportunityIds) {
Opportunity opp = opportunitiesById.get(opportunityId);
if (opp == null) {
continue;
}
if (opp.AccountId == null) {
continue;
}
if (existingOrdersByOpportunityId.containsKey(opportunityId)) {
continue;
}
Order orderToCreate = buildOrderFromOpportunity(opp);
unitOfWork.registerNew(orderToCreate);
}
unitOfWork.commitWork();
}
private Set<Id> extractOpportunityIds(List<Order_Request__e> events) {
Set<Id> opportunityIds = new Set<Id>();
for (Order_Request__e eventRecord : events) {
if (String.isNotBlank(eventRecord.Opportunity_Id__c)) {
opportunityIds.add((Id) eventRecord.Opportunity_Id__c);
}
}
return opportunityIds;
}
private Order buildOrderFromOpportunity(Opportunity opp) {
Order orderRecord = new Order();
orderRecord.AccountId = opp.AccountId;
orderRecord.EffectiveDate = Date.today();
orderRecord.Status = 'Draft';
orderRecord.Source_Opportunity__c = opp.Id;
orderRecord.Description =
'Order created from Closed Won Opportunity: ' + opp.Name;
return orderRecord;
}
}
The service layer owns the business rules. The service does not directly perform DML; it registers records with Unit of Work.
The repository class contains SOQL only.
public with sharing class OrderRepository {
public Map<Id, Opportunity> getOpportunitiesById(Set<Id> opportunityIds) {
if (opportunityIds == null || opportunityIds.isEmpty()) {
return new Map<Id, Opportunity>();
}
return new Map<Id, Opportunity>([
SELECT Id, Name, AccountId, Amount, CloseDate, StageName
FROM Opportunity
WHERE Id IN :opportunityIds
]);
}
public Map<Id, Order> getExistingOrdersByOpportunityId(Set<Id> opportunityIds) {
Map<Id, Order> ordersByOpportunityId = new Map<Id, Order>();
if (opportunityIds == null || opportunityIds.isEmpty()) {
return ordersByOpportunityId;
}
for (Order orderRecord : [
SELECT Id, Source_Opportunity__c
FROM Order
WHERE Source_Opportunity__c IN :opportunityIds
]) {
ordersByOpportunityId.put(
orderRecord.Source_Opportunity__c,
orderRecord
);
}
return ordersByOpportunityId;
}
}
The repository layer centralises SOQL. This avoids spreading queries across triggers and services, making the code easier to test and maintain.
This is a simple custom Unit of Work implementation supporting insert, update, delete, and commit.
public with sharing class ApplicationUnitOfWork {
private List<SObject> recordsToInsert = new List<SObject>();
private List<SObject> recordsToUpdate = new List<SObject>();
private List<SObject> recordsToDelete = new List<SObject>();
public void registerNew(SObject record) {
if (record != null) {
recordsToInsert.add(record);
}
}
public void registerDirty(SObject record) {
if (record != null) {
recordsToUpdate.add(record);
}
}
public void registerDeleted(SObject record) {
if (record != null) {
recordsToDelete.add(record);
}
}
public void commitWork() {
Savepoint sp = Database.setSavepoint();
try {
if (!recordsToInsert.isEmpty()) {
insert recordsToInsert;
}
if (!recordsToUpdate.isEmpty()) {
update recordsToUpdate;
}
if (!recordsToDelete.isEmpty()) {
delete recordsToDelete;
}
clearWork();
} catch (Exception ex) {
Database.rollback(sp);
throw ex;
}
}
private void clearWork() {
recordsToInsert.clear();
recordsToUpdate.clear();
recordsToDelete.clear();
}
}
The Unit of Work pattern batches DML operations and commits them in one place. If something fails, it rolls back the transaction. This keeps DML management consistent and the service layer clean.
Optional Flow Entry Point
If you want to use Flow instead of a trigger, create a Record-Triggered Flow on Opportunity:
Condition: StageName Equals Closed Won
Use this invocable Apex class:
public with sharing class OrderRequestFlowAction {
public class Request {
@InvocableVariable(required=true)
public Id opportunityId;
}
@InvocableMethod(label='Publish Order Request Event')
public static void publishOrderRequest(List<Request> requests) {
Set<Id> opportunityIds = new Set<Id>();
for (Request request : requests) {
if (request.opportunityId != null) {
opportunityIds.add(request.opportunityId);
}
}
if (opportunityIds.isEmpty()) {
return;
}
List<Opportunity> opportunities = [
SELECT Id, AccountId, Amount, CloseDate
FROM Opportunity
WHERE Id IN :opportunityIds
];
List<Order_Request__e> eventsToPublish = new List<Order_Request__e>();
for (Opportunity opp : opportunities) {
if (opp.AccountId != null) {
eventsToPublish.add(new Order_Request__e(
Opportunity_Id__c = String.valueOf(opp.Id),
Account_Id__c = String.valueOf(opp.AccountId),
Amount__c = opp.Amount,
Close_Date__c = opp.CloseDate,
Event_Source__c = 'OpportunityFlow'
));
}
}
if (!eventsToPublish.isEmpty()) {
EventBus.publish(eventsToPublish);
}
}
}
If Flow is used as the entry point, Flow only calls an invocable Apex publisher. The remaining architecture stays the same: Flow → Publisher → Platform Event → API → Service → Repository → Unit of Work.
@IsTest
private class OrderRequestPlatformEventTest {
@IsTest
static void shouldCreateOrderWhenOpportunityIsClosedWon() {
Account acc = new Account(
Name = 'Test Account'
);
insert acc;
Opportunity opp = new Opportunity(
Name = 'Test Opportunity',
AccountId = acc.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(10),
Amount = 1000
);
insert opp;
Test.startTest();
opp.StageName = 'Closed Won';
update opp;
Test.getEventBus().deliver();
Test.stopTest();
List<Order> orders = [
SELECT Id, AccountId, Source_Opportunity__c, Status
FROM Order
WHERE Source_Opportunity__c = :opp.Id
];
System.assertEquals(1, orders.size(), 'One Order should be created');
System.assertEquals(acc.Id, orders[0].AccountId);
System.assertEquals(opp.Id, orders[0].Source_Opportunity__c);
System.assertEquals('Draft', orders[0].Status);
}
@IsTest
static void shouldNotCreateDuplicateOrder() {
Account acc = new Account(
Name = 'Duplicate Test Account'
);
insert acc;
Opportunity opp = new Opportunity(
Name = 'Duplicate Test Opportunity',
AccountId = acc.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(10),
Amount = 2000
);
insert opp;
Order existingOrder = new Order(
AccountId = acc.Id,
EffectiveDate = Date.today(),
Status = 'Draft',
Source_Opportunity__c = opp.Id
);
insert existingOrder;
Test.startTest();
opp.StageName = 'Closed Won';
update opp;
Test.getEventBus().deliver();
Test.stopTest();
List<Order> orders = [
SELECT Id
FROM Order
WHERE Source_Opportunity__c = :opp.Id
];
System.assertEquals(1, orders.size(), 'Duplicate Order should not be created');
}
}
If you create Order Products, you may also need Price Book, Price Book Entry, Product, and OrderItem records.