Thursday, 2 July 2026

Event-Driven Layered Architecture using Platform Events, Service Layer, Repository Pattern, and Unit of Work Pattern.

Use Case 

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 

           ↓ 

Apex Publisher Class 

           ↓ 

Platform Event 

           ↓ 

Platform Event Trigger 

           ↓ 

API / Facade Class 

           ↓ 

Service Class 

           ↓ 

Repository Class 

           ↓ 

Unit of Work 

           ↓ 

DML: Order Created 


Metadata Required 

 Platform Event

Create a Platform Event named: 

Order_Request__e

         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)


 

Custom Field on Order 

Create a custom lookup field on the standard Order object: 

Source_Opportunity__c 

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.


Platform Event Trigger 


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. 

API / Facade Class 


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.

Service Class

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. 

10. Repository Class

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. 

Unit of Work Class

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:

Object: Opportunity

Trigger: When updated

Condition: StageName Equals Closed Won

Action: Apex Action

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.

Test Class 


@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');

}

}

Important Notes

  • In some Salesforce orgs, the standard Order object may require additional setup. 

  • The Order object must be enabled.

  • The value Draft must be available as a valid Order Status.

  • If you create Order Products, you may also need Price Book, Price Book Entry, Product, and OrderItem records. 

  • This example creates only the Order header, not Order Products.