Log Entry
An Error Contract for LWC, Apex, and Flow
Salesforce errors have many shapes.
An LWC might receive:
- an LDS error with
body.output.errors - field errors nested under
body.output.fieldErrors - an Apex exception with
body.message - a network error
- a JavaScript exception
- a GraphQL response containing an
errorsarray
Flow has faults. Apex has exceptions and Database.Error. Users have one question:
What happened, and what can I do now?
If every boundary invents a new answer, the UI fills up with defensive parsing and the logs still fail to identify the operation.
I prefer a small application error contract.
Quick snapshot
Every expected application failure should have:
- a stable machine-readable code
- a safe user-facing message
- optional field or record context
- a retryable flag where it is meaningful
- a correlation ID for diagnostics
Unexpected failures should be logged with technical detail, but returned to the user through a safe generic message.
The rule is:
Codes are for decisions. Messages are for people. Stack traces are for logs.
Do not make one string do all three jobs.
Define the boundary response
For an imperative LWC command, I often prefer returning a typed result for expected business outcomes.
public class OperationResult {
@AuraEnabled public Boolean success;
@AuraEnabled public String errorCode;
@AuraEnabled public String message;
@AuraEnabled public String fieldName;
@AuraEnabled public Boolean retryable;
@AuraEnabled public String correlationId;
}
An expected rejection such as ACCOUNT_ON_HOLD is not necessarily an Apex exception. It is a valid business result from an attempted command.
That distinction makes the client clearer:
const result = await submitOrder({ request });
if (!result.success) {
this.showOperationError(result);
return;
}
Exceptions remain appropriate when the method cannot fulfil its contract: an unexpected database failure, a violated invariant, or infrastructure that is unavailable in a way the local operation cannot resolve.
Use stable codes
Messages change.
They get clarified, translated, and rewritten for different surfaces.
Automation should not branch on them.
Bad:
if (error.body.message.includes('already submitted')) {
// Treat as duplicate.
}
Better:
if (problem.code === 'ORDER_ALREADY_SUBMITTED') {
// Treat as an idempotent duplicate.
}
Keep the code vocabulary small and domain-based. Avoid leaking implementation names such as DML_EXCEPTION_14 into a business API.
Useful codes tend to describe outcomes:
INVALID_REQUESTRECORD_NOT_FOUNDPERMISSION_DENIEDORDER_ALREADY_SUBMITTEDDEPENDENCY_UNAVAILABLECONCURRENT_UPDATE
Do not send technical detail to the browser
An exception can contain object names, field names, query fragments, IDs, or implementation details that should not become user-facing text.
An AuraHandledException can deliberately send a message to an LWC, but that does not make every exception message safe to expose.
I log technical context on the server and return a controlled problem.
try {
return OrderService.submit(request);
} catch (OrderService.BusinessException exceptionValue) {
return failure(
exceptionValue.code,
exceptionValue.getMessage(),
false,
correlationId
);
} catch (Exception exceptionValue) {
ErrorLogger.capture(exceptionValue, correlationId);
throw new AuraHandledException(
'We could not submit the order. Reference: ' + correlationId
);
}
The unexpected path gives support something searchable without giving the user a stack trace.
Normalise platform errors once in LWC
The UI still needs to handle errors that do not come from our Apex contract: LDS, GraphQL, validation, and JavaScript failures.
I put that translation in one small utility rather than copying error?.body?.message into every component.
export function toProblem(error) {
if (Array.isArray(error?.body)) {
return {
code: 'REQUEST_FAILED',
message: error.body.map((item) => item.message).join(', ')
};
}
if (typeof error?.body?.message === 'string') {
return {
code: 'REQUEST_FAILED',
message: error.body.message
};
}
if (typeof error?.message === 'string') {
return {
code: 'CLIENT_ERROR',
message: error.message
};
}
return {
code: 'UNKNOWN_ERROR',
message: 'Something went wrong.'
};
}
The utility can evolve as the application uses more adapters. Components remain concerned with display and recovery, not transport archaeology.
Flow needs structured outcomes too
An invocable Apex action can return the same conceptual fields:
successerrorCodemessagerecordIdretryable
That lets Flow use a Decision element for known outcomes.
Use the Flow fault path for an action that could not execute its contract. Use returned failure results for expected item-level outcomes the Flow is designed to handle.
This is especially important for bulk actions. Throwing one exception can discard useful results for every other input.
Field errors deserve field context
If the problem belongs to one input, say so.
An LWC form can render a field error next to the field only if the contract identifies it. Otherwise every validation failure becomes a page-level toast.
Toasts are useful for operation-level outcomes. They are poor substitutes for precise form validation.
I normally use API names at an internal component boundary and map them to the relevant input. I do not show raw API names to the user.
Retryable is a narrow promise
retryable: true should mean that repeating the same safe operation may succeed without user correction.
Examples might include:
- a temporary dependency outage
- a lock conflict
- a timeout where the operation is protected by an idempotency key
Validation and permission failures are not retryable. Neither is an ambiguous command that might already have completed.
A retry button without idempotency is a duplicate-data button with nicer styling.
Test the contract, not the prose
Tests should normally assert:
- the error code
- success or failure
- relevant context
- whether retry is allowed
Avoid asserting an entire user-facing paragraph unless the exact copy is itself a requirement.
That keeps tests stable while allowing the message to improve.
The result
One error contract does not erase every Salesforce error shape.
It gives the application one place to translate those shapes and one vocabulary for the failures it owns.
That means fewer mystery toasts, fewer Flows parsing prose, and fewer support tickets where the only diagnostic detail is "script-thrown exception".
I will take that trade every time.