Skip to content

Write a custom Apex Check

Detailed page. Use “On this page” to jump directly to the section you need.

Implement a custom Apex Check with the public plugin contract on this page. An administrator selects Verify with Apex for Evaluation Type and pastes a reviewed class API name into the Check record; they do not need to implement this contract. Use a Formula or Query Check when either can express the rule safely.

Create an Apex class for logic that cannot be expressed with a Formula or Query Check. The class receives up to 200 record IDs at once and must return one result for every ID.

global interface RecordHealthCheckPlugin {
Map<Id, RecordHealthCheckOutcome> evaluate(RecordHealthCheckScope scope);
}

This is the interface declared inside the installed package. A class created in your org uses the rhc. prefix, as shown in the complete example below.

Version Record Health Check also provides three optional interfaces/helpers: declare parameters with RecordHealthCheckPluginDefinitionSource, isolate ordinary per-record failures with RecordHealthCheckRecordEvaluator plus RecordHealthCheckOutcome.tryEvaluate, and add card-only presentation with RecordHealthCheckDisplayPlugin. The complete public contract lists every builder, limit, failure rule, and fallback.

Declare the class global with sharing so the installed package can call it. Run every SOQL query with the intended user access; the example uses WITH USER_MODE. Record Health Check validates the returned record IDs, Statuses, Reason Codes, and prohibited actions, but your class remains responsible for object, field, and record access in its own queries.

Treat deployment of a custom Apex Check like any other security-sensitive Apex deployment. with sharing enforces record sharing, but it does not by itself enforce object and field access. Record Health Check can detect prohibited changes; it cannot prove whether every custom query used user mode. A code review is therefore required even when the contract test passes.

The Scope contains the information supplied to the custom Apex Check.

rhc.RecordHealthCheckScope provides:

PropertyMeaning
objectApiNameObject shared by the requested IDs
recordIdsCopy of all requested record IDs in order
recordIdAt(index)One ID at a numbered position, without creating another list copy
parametersParsed Check parameter JSON
checkDeveloperNameUnqualified Check Developer Name
checkQualifiedApiNameSelected Check identity
checkSetDeveloperNameUnqualified parent Check Set Developer Name
checkSetQualifiedApiNameParent Check Set identity
runIdID that connects results, logs, and events from the same run

The class cannot change the package’s original request. Read recordIds into a local variable once, query for all IDs together, organize the query results in a map, and then build one outcome per record. Each access to scope.recordIds returns another list copy. For a numbered loop, use scope.size() and scope.recordIdAt(index) instead.

Use the qualified properties when identity must remain unique across namespaces. The Developer Name properties are convenient when the unqualified metadata name is specifically required, but two namespaces can publish the same Developer Name.

Return one Outcome for every requested record ID.

Build outcomes with the factories on rhc.RecordHealthCheckOutcome:

rhc.RecordHealthCheckOutcome.pass('RECENT_ACTIVITY_FOUND')
.withFound(rhc.RecordHealthCheckValue.ofCount(3))
.withComparison(
'GREATER_THAN',
rhc.RecordHealthCheckValue.ofCount(0)
);

Available Status factories are pass, fail, unableToEvaluate, and skipped. error is reserved for package contract failures. Found and Expected use rhc.RecordHealthCheckValue factories for String, Boolean, Number, Date, DateTime, ID, Count, and List values.

RecordHealthCheckValue carries a typed value, not a currency unit. The package cannot inspect or repair arithmetic already performed inside a subscriber plugin, so custom Apex owns its currency correctness. If a plugin compares monetary values, query and retain their ISO units explicitly and refuse or deliberately normalize them according to the plugin’s documented contract. The core framework performs no currency conversion and does not infer plugin-internal units from display labels.

The custom class does not set record identity, Check identity, Severity, applicability, publication, or diagnostics. Check metadata owns those values. An optional display plugin can add presentation only; it cannot change evaluation.

Implement rhc.RecordHealthCheckPluginDefinitionSource when a Check accepts JSON parameters. The definition can declare integer, choice, string, and Boolean inputs; defaults; bounds; required and nullable behavior; administrator labels/help; and scope capacity. The engine rejects unknown keys, duplicate keys, wrong JSON types, invalid choices, and out-of-range values before evaluation. This gives administrators a stable contract and removes type guessing from plugin code.

Use rhc.RecordHealthCheckEvidence when Found and Expected alone do not explain the decision. Declare typed columns, add rows in the same order, and attach the snapshot with .withEvidence(). Synthetic cells use RecordHealthCheckEvidenceCell.value; cells derived from a business-record field use .field(recordId, fieldPath, value) so the framework can enforce field access. Evidence is bounded and explanatory. See evidence limits and permission behavior.

When data is already loaded in bulk, RecordHealthCheckOutcome.tryEvaluate(recordId, evaluator) can turn one ordinary record exception into RECORD_EVALUATION_FAILED and a null record result into RECORD_RESULT_MISSING. Permission failures, fatal failures, and side-effect violations still escape to the engine. Never use this helper to place SOQL or DML inside the per-record loop.

An Apex Check must return Found and Expected values with their Salesforce data types for every PASS or FAIL outcome. The Check fields Display: Found Text and Display: Expected Text do not wrap or replace those custom Apex values. If either field is populated on an Apex Check, metadata validation reports the non-blocking APEX_DISPLAY_TEXT_IGNORED warning. Put the values in the custom class with .withFound(), .withExpected(), or .withComparison(); use the Check failure message when administrators need configurable explanatory wording.

For a request using EVALUATION_WITH_DISPLAY, a custom Check may also implement rhc.RecordHealthCheckDisplayPlugin. The callback runs once on the same instance after evaluation, so it must reuse data already loaded by evaluate; it cannot query or perform side effects.

The optional override can provide rich message, remediation, Found and Expected content; one atomic action label and destination; an Expected label; and independent Found/Expected formats with an optional currency ISO code. A missing or invalid field falls back to Check configuration. Status, typed values, severity, identity, order, category, applicability, visibility and publication policy remain framework or administrator owned.

Let the plugin decide which records belong to each business group, then let the display API own label/item separators and line breaks. The default item separator is comma-space, and every saved record receives its own canonical Lightning record link.

List<SObject> stepOneRecords = new List<SObject>();
stepOneRecords.addAll(stepOneUsers);
List<SObject> stepTwoRecords = new List<SObject>();
stepTwoRecords.addAll(stepTwoUsers);
List<SObject> stepThreeRecords = new List<SObject>();
stepThreeRecords.addAll(stepThreeUsers);
List<rhc.RecordHealthCheckDisplayGroup> groups =
new List<rhc.RecordHealthCheckDisplayGroup>{
new rhc.RecordHealthCheckDisplayGroup('step-1')
.withLabel(new rhc.RecordHealthCheckDisplayText().link('Step 1', stepOneUrl))
.withItems(new rhc.RecordHealthCheckDisplayText().recordLinks(stepOneRecords, User.Name)),
new rhc.RecordHealthCheckDisplayGroup('step-2')
.withLabel(new rhc.RecordHealthCheckDisplayText().link('Step 2', stepTwoUrl))
.withItems(new rhc.RecordHealthCheckDisplayText().recordLinks(stepTwoRecords, User.Name)),
new rhc.RecordHealthCheckDisplayGroup('step-3')
.withLabel(new rhc.RecordHealthCheckDisplayText().link('Step 3', stepThreeUrl))
.withItems(new rhc.RecordHealthCheckDisplayText().recordLinks(stepThreeRecords, User.Name))
};
rhc.RecordHealthCheckDisplayText found =
new rhc.RecordHealthCheckDisplayText().groups(groups);
return new rhc.RecordHealthCheckDisplayOverride()
.withFound(found)
.withMessage(new rhc.RecordHealthCheckDisplayText().text('Approval review is required.'))
.withFix(new rhc.RecordHealthCheckDisplayText().text('Complete each approval step.'))
.withAction(new rhc.RecordHealthCheckDisplayAction('Open approvals', '/lightning/page/home'))
.withExpectedLabel('Required state');

The input lists may have different sizes, for example one, two, and three Users. The resulting groups render on separate lines, and both each Step label and each saved User are independently clickable. Group keys must be unique and nonblank. Group and item order are preserved, including duplicates. Empty groups are hidden by default; add .withEmptyState(new rhc.RecordHealthCheckDisplayText().text('No users')) to show one. Use .withSeparator(' -> ') to replace the default colon-space between the label and items. Null records and records with blank labels are skipped. Records without an Id render as plain text because Salesforce has no record page to open.

This example checks whether each Account has at least one Contact. It first creates a FAIL outcome with a count of zero for every Account. One grouped query finds Accounts that have Contacts, and the code replaces only those outcomes with PASS. Accounts with no query row still have a result.

global with sharing class ContactPresenceCheck implements rhc.RecordHealthCheckPlugin {
global Map<Id, rhc.RecordHealthCheckOutcome> evaluate(
rhc.RecordHealthCheckScope scope
) {
// Read the property once because it returns a new list copy each time.
List<Id> accountIds = scope.recordIds;
Map<Id, rhc.RecordHealthCheckOutcome> outcomes = new Map<Id, rhc.RecordHealthCheckOutcome>();
for (Id accountId : accountIds) {
outcomes.put(
accountId,
rhc.RecordHealthCheckOutcome.fail('NO_CONTACTS')
.withFound(rhc.RecordHealthCheckValue.ofCount(0))
.withComparison('GREATER_THAN', rhc.RecordHealthCheckValue.ofCount(0))
);
}
// One query checks every Account. Do not put SOQL inside the Account loop.
for (AggregateResult row : [
SELECT AccountId parentId, COUNT(Id) total
FROM Contact
WHERE AccountId IN :accountIds
WITH USER_MODE
GROUP BY AccountId
]) {
Id recordId = (Id) row.get('parentId');
Integer total = (Integer) row.get('total');
outcomes.put(
recordId,
rhc.RecordHealthCheckOutcome.pass('CONTACTS_FOUND')
.withFound(rhc.RecordHealthCheckValue.ofCount(total))
.withComparison('GREATER_THAN', rhc.RecordHealthCheckValue.ofCount(0))
);
}
return outcomes;
}
}

Keep custom Apex Checks within these boundaries

Section titled “Keep custom Apex Checks within these boundaries”

A custom Apex Check must not create, update, or delete records; publish events; enqueue work; send email; make callouts; or start asynchronous Apex. Record Health Check uses a savepoint and governor counters to reject observable prohibited actions. Apex exposes no reliable counter proving that a plugin did not call EventBus.publish, start Batch Apex, or schedule Apex, so contract tests, static analysis, and human code review must enforce those prohibitions. Runtime detection is not a complete sandbox.

Catch a problem that affects only one record inside the record loop and return UNABLE_TO_EVALUATE for that record so one problem does not erase the results for every other record in the request.

Extend rhc.RecordHealthCheckContractTest, provide a new custom Apex Check instance and an rhc.RecordHealthCheckContractTestData factory, then call verifyContract() from an Apex test. The contract test measures scopes of 1, 10, 50, and 200 records.

To test access behavior, create a user who genuinely cannot read a record or field used by the Check. Apex does not expose a counter that proves whether a query used user mode, so the test result and a review of the SOQL source remain separate requirements.

The framework’s configured Query row limit does not wrap SOQL issued inside a subscriber plugin. Plugin authors own their row limits: query no more than the plugin can evaluate completely, use an extra-row probe when a collection-wide result depends on completeness, and return a documented UNABLE_TO_EVALUATE outcome instead of deciding from a truncated collection. Do not disclose a true count that the running user may not be entitled to see.

The interface shown on this page is the supported custom Apex Check contract. Implement rhc.RecordHealthCheckPlugin with rhc.RecordHealthCheckScope, rhc.RecordHealthCheckOutcome, and rhc.RecordHealthCheckValue; the package does not include a second custom Apex Check interface.

The response and Platform Event contract versions are independent of this interface. Compile and run the contract test against the package version you plan to install. An event or response version does not prove that a custom Apex Check compiles with that package version.