Validate changed fields from an Apex trigger

Run Plauti Verify validation automatically when a record is saved, without using the entry form override

Plauti Verify exposes its validation as Apex invocable actions — the same building blocks the Verify screens and flows are built on. You can call them from your own automation to enforce data quality exactly where your business needs it, while Plauti still does the heavy lifting: the validation runs through Plauti's data quality API and respects the fields you configured for the object in the Verify setup.

This article shows one such use — validating a record whenever a user updates it, using an after update Apex trigger, without an entry form. Not every business wants validation to run on every update, so treat this as an opt-in pattern that you stay in control of: which object it runs on, which trigger context fires it, and what happens with the results. It takes very little code, because the managed package does the validation work.

The example also validates only the fields that actually changed. A save that does not touch a validated field triggers no validation call and consumes no credits.

📘

Verify must be installed and configured

Validation uses the object's Plauti Verify configuration to decide which fields to validate. The address, email, and phone fields you want validated must be configured for that object in the Verify setup, and the org must be licensed for Verify.

How it works

A record update runs in a synchronous context, but Verify validation makes callouts, so the work has to move to an asynchronous context. The handler does this in two stages:

  1. In the trigger (sync): for the fields you choose to watch, compare each record's new values against the old values and collect the names of the fields that changed. If none changed, stop here — no asynchronous work and no callout.
  2. In a @future method (async): re-query those records for the changed fields (so the values keep their native types), rebuild a trimmed copy of each record containing only the changed fields, call Rv2ValidateAllFlowAction.validateAll to validate it, then call Rv2SaveValidationResultsFlowAction.saveValidationResults to persist the results.

Saving the results updates the record, which fires the after update trigger again. The handler guards against this with System.isFuture() — on the second pass it is already in the future context, so it returns immediately and the loop ends.

validateAll only validates fields that are present on the record it receives (it skips anything not returned by getPopulatedFieldsAsMap()). That is why the handler rebuilds a trimmed record: passing only the changed fields means only those fields are validated and charged.

The Verify flow actions

The handler is thin because Plauti Verify does the validation. Two global invocable actions in the recordval namespace are the building blocks. Their method signatures are visible in a subscriber org, but the behaviour below is not — account for it when you build on them.

Rv2ValidateAllFlowAction.validateAll

Validates the address, email, and phone fields configured for an object and writes the resulting statuses back to the record.

global static List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllOutput> validateAll(
    List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllInput> inputs
)

Input — rv2ValidateAllInput:

FieldTypeDescription
recordObjectSObjectThe record to validate. Required.
addressAutoProcessingScenarioNameStringOptional. Name of a configured address auto-processing scenario.
emailAutoProcessingScenarioNameStringOptional. Name of a configured email auto-processing scenario.
phoneAutoProcessingScenarioNameStringOptional. Name of a configured phone auto-processing scenario.

Output — rv2ValidateAllOutput:

FieldTypeDescription
successBooleanTrue when the validation call completed.
statusCodeStringStatus code of the bulk validation.
statusMessageStringHuman-readable status message.
creditBooleanWhether credit was consumed.
addressValidationResultsList<Rv2FlowValidationResult>One result per validated address field.
emailValidationResultsList<Rv2FlowValidationResult>One result per validated email field.
phoneValidationResultsList<Rv2FlowValidationResult>One result per validated phone field.

Behaviour to account for:

  • Pass exactly one input. Zero or more than one throws recordval.rv2Exception.FlowException.
  • Only populated fields on recordObject are validated; fields absent from the record are skipped. This is what lets you validate (and pay for) only the changed fields.
  • validateAll writes the validation status back to the record as part of the call.
  • addressValidationResults is always populated. emailValidationResults and phoneValidationResults are only populated when the matching auto-processing scenario name is supplied on the input.
  • success is false when the validation did not complete — check it before using the results.

Rv2SaveValidationResultsFlowAction.saveValidationResults

Persists a set of validation results to a record.

global static List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultOutput> saveValidationResults(
    List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultRequest> requests
)

Request — Rv2SaveValidationResultRequest:

FieldTypeDescription
recordIdIdThe record to update. Required.
validationResultsList<Rv2FlowValidationResult>The results to persist.

Output — Rv2SaveValidationResultOutput:

FieldTypeDescription
successBooleanTrue when the save succeeded.
recordIdIdThe record that was updated.

Behaviour to account for:

  • Pass exactly one request. Zero or more than one throws recordval.rv2Exception.FlowException.
  • A blank recordId throws.
  • Every fieldName in validationResults must correspond to a field configured for that object in the Verify setup, otherwise it throws.

Rv2FlowValidationResult

The result for a single field — returned by validateAll and accepted by saveValidationResults.

FieldTypeDescription
fieldNameStringAPI name of the validated field.
adviceStringValidation advice — GREEN, AMBER, or RED.
statusCodeStringStatus code for the field.
statusMessageStringStatus message for the field.
creditBooleanWhether credit was consumed for the field.
geoStatusCodeStringGeocoding status code (address fields).
geoStatusMessageStringGeocoding status message (address fields).
geoCreditBooleanWhether geocoding credit was consumed.
📘

validateAll already writes status back

Because validateAll writes the validation status to the record itself, the separate saveValidationResults call is only needed when you collect results and persist them as a distinct step (as this example does, mirroring the flow pattern). If you rely solely on validateAll, you can drop the save call.

The trigger

trigger LeadValidationTrigger on Lead(after update) {
    Set<String> fieldsToValidate = new Set<String>{
        'Email',
        'Phone',
        'Street',
        'City',
        'State',
        'PostalCode',
        'Country'
    };

    ValidateChangedFieldsTriggerHandler.handleAfterUpdate(Trigger.new, Trigger.oldMap, fieldsToValidate);
}

List the fields you want to guard in fieldsToValidate — typically the address, email, and phone fields configured for the object in the Verify setup.

🚧

Written for single-record updates

This example is intended for single-record saves, such as a user editing a record or a single-record API update. It still runs for several records in one transaction, but it calls validateAll and saveValidationResults once per record — one callout each. Large bulk updates can therefore hit the future-method limit (50 per transaction) or callout limits. For high-volume bulk processing, adapt the async stage to chunk the work, for example with a Queueable or Batch class.

The trigger handler

public with sharing class ValidateChangedFieldsTriggerHandler {
    public interface IRecordValidationService {
        List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllOutput> validateAll(
            List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllInput> inputs
        );
        List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultOutput> saveValidationResults(
            List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultRequest> requests
        );
    }

    public class RecordValidationService implements IRecordValidationService {
        public List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllOutput> validateAll(
            List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllInput> inputs
        ) {
            return recordval.Rv2ValidateAllFlowAction.validateAll(inputs);
        }

        public List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultOutput> saveValidationResults(
            List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultRequest> requests
        ) {
            return recordval.Rv2SaveValidationResultsFlowAction.saveValidationResults(requests);
        }
    }

    @TestVisible
    private static IRecordValidationService validationService = new RecordValidationService();

    public static void handleAfterUpdate(
        List<SObject> newRecords,
        Map<Id, SObject> oldMap,
        Set<String> fieldsToValidate
    ) {
        if (System.isFuture() || System.isBatch() || System.isQueueable()) {
            return;
        }
        if (fieldsToValidate == null || fieldsToValidate.isEmpty()) {
            return;
        }

        Map<Id, List<String>> changedFieldsByRecordId = new Map<Id, List<String>>();
        for (SObject newRecord : newRecords) {
            List<String> changedFields = getChangedFields(newRecord, oldMap.get(newRecord.Id), fieldsToValidate);
            if (!changedFields.isEmpty()) {
                changedFieldsByRecordId.put(newRecord.Id, changedFields);
            }
        }

        if (changedFieldsByRecordId.isEmpty()) {
            return;
        }

        validateChangedFieldsAsync(JSON.serialize(changedFieldsByRecordId));
    }

    @future(callout=true)
    private static void validateChangedFieldsAsync(String changedFieldsByRecordIdJson) {
        processValidation(changedFieldsByRecordIdJson);
    }

    private static void processValidation(String changedFieldsByRecordIdJson) {
        Map<Id, List<String>> changedFieldsByRecordId = deserializeChangedFields(changedFieldsByRecordIdJson);
        if (changedFieldsByRecordId.isEmpty()) {
            return;
        }

        Set<String> allChangedFields = new Set<String>();
        for (List<String> fieldNames : changedFieldsByRecordId.values()) {
            allChangedFields.addAll(fieldNames);
        }

        // Re-query in the async context so values keep their native types.
        Map<Id, SObject> recordsById = queryRecords(changedFieldsByRecordId.keySet(), allChangedFields);

        for (Id recordId : changedFieldsByRecordId.keySet()) {
            SObject fullRecord = recordsById.get(recordId);
            if (fullRecord == null) {
                continue;
            }
            try {
                validateAndSave(recordId, fullRecord, changedFieldsByRecordId.get(recordId));
            } catch (Exception e) {
                // Isolate failures so one bad record does not abort the rest.
                System.debug(LoggingLevel.ERROR, 'Verify validation failed for ' + recordId + ': ' + e.getMessage());
            }
        }
    }

    private static void validateAndSave(Id recordId, SObject fullRecord, List<String> changedFields) {
        SObject trimmedRecord = recordId.getSObjectType().newSObject(recordId);
        for (String fieldName : changedFields) {
            trimmedRecord.put(fieldName, fullRecord.get(fieldName));
        }

        recordval.Rv2ValidateAllFlowAction.rv2ValidateAllInput input = new recordval.Rv2ValidateAllFlowAction.rv2ValidateAllInput();
        input.recordObject = trimmedRecord;

        List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllOutput> validateOutputs = validationService.validateAll(
            new List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllInput>{ input }
        );

        if (validateOutputs.isEmpty() || validateOutputs[0].success != true) {
            return;
        }

        List<recordval.Rv2FlowValidationResult> results = new List<recordval.Rv2FlowValidationResult>();
        addResults(results, validateOutputs[0].addressValidationResults);
        addResults(results, validateOutputs[0].emailValidationResults);
        addResults(results, validateOutputs[0].phoneValidationResults);

        if (results.isEmpty()) {
            return;
        }

        recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultRequest saveRequest = new recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultRequest();
        saveRequest.recordId = recordId;
        saveRequest.validationResults = results;

        validationService.saveValidationResults(
            new List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultRequest>{ saveRequest }
        );
    }

    private static Map<Id, SObject> queryRecords(Set<Id> recordIds, Set<String> fieldNames) {
        Set<String> selectFields = new Set<String>(fieldNames);
        selectFields.add('Id');
        String objectName = new List<Id>(recordIds)[0].getSObjectType().getDescribe().getName();
        String query =
            'SELECT ' +
            String.join(new List<String>(selectFields), ', ') +
            ' FROM ' +
            objectName +
            ' WHERE Id IN :recordIds';
        return new Map<Id, SObject>(Database.query(query));
    }

    private static Map<Id, List<String>> deserializeChangedFields(String changedFieldsByRecordIdJson) {
        Map<String, Object> raw = (Map<String, Object>) JSON.deserializeUntyped(changedFieldsByRecordIdJson);
        Map<Id, List<String>> changedFieldsByRecordId = new Map<Id, List<String>>();
        for (String recordIdString : raw.keySet()) {
            List<String> fieldNames = new List<String>();
            for (Object fieldName : (List<Object>) raw.get(recordIdString)) {
                fieldNames.add((String) fieldName);
            }
            changedFieldsByRecordId.put((Id) recordIdString, fieldNames);
        }
        return changedFieldsByRecordId;
    }

    private static void addResults(
        List<recordval.Rv2FlowValidationResult> target,
        List<recordval.Rv2FlowValidationResult> source
    ) {
        if (null != source) {
            target.addAll(source);
        }
    }

    private static List<String> getChangedFields(SObject newRecord, SObject oldRecord, Set<String> fieldsToValidate) {
        List<String> changedFields = new List<String>();
        if (oldRecord == null) {
            return changedFields;
        }
        for (String fieldName : fieldsToValidate) {
            if (newRecord.get(fieldName) != oldRecord.get(fieldName)) {
                changedFields.add(fieldName);
            }
        }
        return changedFields;
    }
}

handleAfterUpdate takes the set of fields you want to watch, so an update that does not touch one of them does no asynchronous work and makes no callout. Keep that set aligned with the fields configured for the object in the Verify setup.

The @future payload carries only the changed field names. processValidation re-queries the records for those fields, so the values keep their native types instead of the strings a JSON round-trip would produce. Each record is then validated inside its own try/catch, so a failure on one record is logged without aborting the others. Both flow actions accept exactly one item per call and throw a FlowException otherwise, so the handler calls them once per record.

Making it testable

Calling the managed package directly from processValidation would make the package run during your unit tests, consuming credits and depending on org configuration. To avoid that, the package calls are placed behind a small interface:

IRecordValidationService            (interface)
        │
        ├── RecordValidationService      (real — delegates to recordval.*)
        │
        └── MockRecordValidationService  (test — captures input, returns stubs)
                    │
                    └── injected through the @TestVisible static field

The handler talks to validationService, which defaults to the real implementation. The @TestVisible static field lets a test swap in a mock at the start of the test method — no dependency-injection framework required. You can use your own mocking framework instead; the point is to control which paths run in the class under test.

The test class

@IsTest
private class ValidateChangedFieldsTriggerHandlerTest {
    private class MockRecordValidationService implements ValidateChangedFieldsTriggerHandler.IRecordValidationService {
        public List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllInput> capturedInputs;
        public List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultRequest> capturedSaveRequests;

        public List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllOutput> validateAll(
            List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllInput> inputs
        ) {
            capturedInputs = inputs;

            recordval.Rv2ValidateAllFlowAction.rv2ValidateAllOutput output = new recordval.Rv2ValidateAllFlowAction.rv2ValidateAllOutput();
            output.success = true;
            output.addressValidationResults = new List<recordval.Rv2FlowValidationResult>();
            output.emailValidationResults = new List<recordval.Rv2FlowValidationResult>();
            output.phoneValidationResults = new List<recordval.Rv2FlowValidationResult>();
            return new List<recordval.Rv2ValidateAllFlowAction.rv2ValidateAllOutput>{ output };
        }

        public List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultOutput> saveValidationResults(
            List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultRequest> requests
        ) {
            capturedSaveRequests = requests;
            return new List<recordval.Rv2SaveValidationResultsFlowAction.Rv2SaveValidationResultOutput>();
        }
    }

    @IsTest
    static void onlyChangedWatchedFieldsAreValidated() {
        MockRecordValidationService mock = new MockRecordValidationService();
        ValidateChangedFieldsTriggerHandler.validationService = mock;

        // Insert the record in its post-update state; the @future re-queries this committed state.
        Lead record = new Lead(LastName = 'Acme', Company = 'Acme', Email = '[email protected]');
        insert record;

        Lead previousState = record.clone(true, true, false, false);
        previousState.Email = '[email protected]';

        Test.startTest();
        ValidateChangedFieldsTriggerHandler.handleAfterUpdate(
            new List<Lead>{ record },
            new Map<Id, SObject>{ record.Id => previousState },
            new Set<String>{ 'Email', 'Phone' }
        );
        Test.stopTest();

        System.Assert.areEqual(1, mock.capturedInputs.size(), 'validateAll should run once for the single record');

        Map<String, Object> validatedFields = mock.capturedInputs[0].recordObject.getPopulatedFieldsAsMap();
        System.Assert.isTrue(validatedFields.containsKey('Email'), 'The changed watched field should be validated');
        System.Assert.isFalse(validatedFields.containsKey('Phone'), 'An unchanged watched field should not be validated');
    }
}

Because the mock is injected before Test.stopTest(), the @future work runs against it when the test releases the asynchronous queue, and the assertions inspect what the handler passed to the package. The record is inserted in its post-update state so the async re-query returns the values the handler validates.

Considerations

ConcernDetail
Watched fieldsOnly the fields you list in fieldsToValidate are compared and validated, so unrelated edits do no async work and make no callout. Keep the list aligned with the object's Verify field configuration.
Single record vs bulkThe example calls validateAll and saveValidationResults once per record (one callout each), though it re-queries all records in a single SOQL. For large bulk updates, chunk the async stage (Queueable/Batch) to stay within future and callout limits.
One item per callBoth flow actions throw a FlowException unless exactly one item is passed. Call them per record, as the handler does.
@future and field types@future accepts only primitives, so the handler passes a JSON string of changed field names (not values) and re-queries the records in the async context. This keeps native types (Date, Datetime, numbers) intact — a JSON round-trip of the values would turn them into strings and break the record rebuild.
Error isolationEach record is validated inside its own try/catch, so a failure on one record (for example a field not configured in Verify) is logged and does not abort the others. Replace the System.debug with your own logging if you have a framework.
Loop preventionSaving results updates the record and re-fires the trigger; the System.isFuture() guard stops the second pass.
Credit usageOnly the changed, watched fields are sent for validation, so only those consume credit.
Email and phone resultsvalidateAll only returns email and phone results when the matching auto-processing scenario name is set on the input; address results are always returned. Set emailAutoProcessingScenarioName / phoneAutoProcessingScenarioName on the input if you need those results to save.

Productionizing further

The example targets single-record updates running in system context. Two areas deserve a deliberate decision before you rely on it at scale.

Bulk updates

validateAll and saveValidationResults accept one record per call, so each changed record is one validation callout and one save callout. A single transaction allows 100 callouts and 50 @future invocations, so a large bulk update — a data load or a mass update — can exceed those limits within one future. The example is therefore safe for interactive edits and small updates, but not for high-volume DML.

For bulk, move the async stage into a Queueable (or Batch) that processes the records in chunks and re-enqueues itself until all are done, so each chunk stays within the callout limit. The change detection and re-query stay the same; only the dispatch changes.

Execution context and field security

The handler runs in system context: the re-query reads every watched field regardless of the running user's field-level security, so validation behaves the same no matter who saved the record. This is usually what you want for a trigger.

If you instead need validation to respect the running user's permissions, query in user mode — for example ... WHERE Id IN :recordIds WITH USER_MODE — but be aware that fields the user cannot see will then not be validated.

Testing across the async boundary

The test injects the mock before Test.stopTest(), which is when the @future work runs. The injected @TestVisible static is in scope for that execution, so the mock — not the managed package — handles the call. Insert each record in its post-update state so the async re-query returns the values the handler validates, and assert against what the mock captured.