Error Handling in a Business Central AL Extension: What Two Real Codebases Do
AL gives you four ways to deal with an error: raise it with a label, collect several before you stop, catch it in a try function, or isolate it behind Codeunit.Run. The choice is never about elegance. It is about two things the user will live with afterwards: what they see on screen, and what the database keeps when the code stops. Get either one wrong and the extension looks fine in a demo and lies in production.
What follows is what two of our AL codebases actually do. One is a line-of-business product of 972 AL files, 174 codeunits and 316 live Error calls. The other is our AppSource e-invoicing app: 118 production files, 61 test codeunits, 576 tests.
Why should an AL error message never be a hardcoded string?
Because a string you cannot find is a string you cannot translate, test or trace. Of the 316 live Error call sites in the larger product, 60 pass an empty string and none passes literal text; the only literal in the repository sits in a commented-out line. Every other call passes a Label variable or an ErrorInfo. The e-invoicing app scores the same on its 54 live production sites: not one literal.
That is not discipline, it is a ruleset. The analyzer configuration in both repositories flags an Error call with raw text, and we keep that rule on because telemetry needs a stable identifier, not a sentence that changes with every wording review. Error labels use an Err suffix, and they are the largest label category in the product, 271 of 697 labels.
The payoff shows up in tests. When production raises a label, the test asserts against the same symbol and breaks only when the logic changes, not the wording. It also survives a French sandbox: a test that once matched an English fragment of a translated label stayed green in CI, which runs en-US, and failed in fr-FR, so that assertion now pins the session language.
What is Error(‘’) actually for?
For cancelling, not for failing. Microsoft’s documentation says it plainly: calling Error with an empty string ends the execution of AL code without displaying a message. The transaction rolls back and the user sees nothing.
Of the 60 empty-string calls in the product, the dominant shape is one line after a confirmation dialog: if the user answers No, call Error(‘’). The dialog was the message; the empty error is the rollback. There are 61 calls to Confirm Management in that codebase and 60 empty errors, and the two counts track each other for that reason. A second use disables lookup and drill-down on a field, a deliberate no-op the user cannot click through.
The rule that comes with it: an empty error is silent by design, so it is never the right response to a real failure. If something went wrong, someone must be able to find out what.
How do collectible errors work in AL, and when do they pay off?
They pay off whenever a user would otherwise fix one field, click again, and meet the next error. Since runtime 8.0, a procedure decorated with the ErrorBehavior Collect attribute keeps running past a collectible error and gathers it; at the end you ask HasCollectedErrors and GetCollectedErrors and decide what to show. One detail: ErrorInfo.Create() with no arguments creates the error as collectible, so the property never has to be set by hand.
The product uses this in a template repeated for each of its main entities. A readiness-check codeunit clears the collected errors on entry, runs every check to the end, and stores the list. Each check raises an ErrorInfo carrying the RecordId and the FieldNo it belongs to, which is what lets the message land on a row and a column rather than in a generic dialog. Several tables expose an IsReady procedure that returns a boolean, and four of them also offer an overload that fills a temporary Error Message buffer, so callers decide for themselves whether to display, abort, or flag the record and move on.
Two design choices are worth copying. First, checks that reuse the platform’s own validation wrap a plain TestField in a try function, then re-raise GetLastErrorText into the collection. The message you get is the one Business Central already wrote, localised and captioned with the field name. Second, the collected ErrorInfo list is converted into the standard Error Message table through LogDetailedMessage, so the ordinary Error Messages page renders it. Two error models, one bridge, no custom page.
One caution comes straight from Microsoft’s documentation: clearing the collected list does not roll back the database, so the documented pairing is to collect inside an if Codeunit.Run block.
When is a try function the wrong tool?
Whenever it hides a failure, and whenever it writes. Changes made to the database inside a try function are not rolled back when the error occurs. Business Central online places no restriction on writes inside try functions; on-premises blocks them by default. In the e-invoicing app’s own test runs, a write inside a try function failed twice, so the repository treats it as forbidden everywhere and says so in the code.
The product has 30 try functions and 126 calls to GetLastErrorText. The four-to-one ratio comes from three generic wrappers, TryGet, TryValidate and TryFindFirst, that accept any record as a Variant and are called from 84 sites. That is the idiom: a try function exists so the batch can continue, and every failure branch ends in a logged error, never in nothing.
The e-invoicing app pushes the same rule to its edge. Its HTTP transport codeunit is documented as “nothing here may raise”: a blank URL or an illegal header is captured through the boolean return of SetRequestUri and Send, and reported as status 0, because an error thrown that deep would travel past every try wrapper and abort the whole job queue entry. The try functions that guard non-critical steps, such as recording an approval after posting, each end in a telemetry line carrying GetLastErrorText, with a comment saying why: an unhandled error there would roll back the posting.
The one reversal in the product is instructive: an integration with an external service once carried the full collect-and-log block, now commented out and replaced with a bare exit(false). The caller learns that the check failed and nothing about why. A boolean that used to be a reason.
When do you isolate a failure behind Codeunit.Run instead?
When the failure must not take the rest of the work down with it, and when a rollback is what you want. With the return value used, Codeunit.Run commits the codeunit’s changes at the end unless an error occurs, and if you are already in a transaction you must commit first. So it isolates with rollback, which a try function does not.
The e-invoicing app runs each of its data migrations in its own Codeunit.Run. When one fails, the text of the failure goes to telemetry through GetLastErrorText, with Verbosity Error and the widest scope, and the next migration still runs. The comment says why it does not raise instead: a raise was the defect being fixed, and here it would also abort the other migration.
The product goes the other way on purpose. Five Codeunit.Run sites in 972 files, and 135 explicit Commit calls, nearly all with a justifying comment because the ruleset warns on a bare Commit. The recurring reasons are commit after each message so one poison message does not roll back the ones before it, and commit every thousand rows to avoid long transactions. A failed message is marked processed and ignored, committed, and left for a replay page. Partial failure is a written policy, not an accident.
What does the user actually see when a job queue entry fails?
Whatever you decided to keep. When the object a job queue entry runs throws an error, the Job Queue Error Handler sets the entry to Error, saves the errors through Error Message Management, and either stops or reschedules depending on Maximum No. of Attempts and the rerun delay. The user gets a Show Error action on the entry.
The product designs nothing extra. It reads the platform’s own Job Queue Log Entry through FlowFields on its configuration table, exposes last success and last error timestamps, and its Show Error action delegates to the entry’s own ShowErrorMessage. Zero duplicated storage.
The e-invoicing app cannot afford that minimalism, because its failures happen against an external platform. So it keeps an API exchange log: one row per call, with the HTTP method, the status code the platform really answered, a Successful flag, and both bodies. A test asserts that a failed fetch leaves a line with status 422 and Successful set to false, and the assertion message says why: that status is what tells a transient 503 apart from a permanent 422 that needs chasing with the platform.
One honest limit is written in the tests themselves. Telemetry emitted with Session.LogMessage has no observable surface in AL: no event, no injectable logger, nothing a test can read back. The app has 17 telemetry calls in production, and the test files say in so many words that those traces are review-enforced, not test-enforced.
How do you test error paths in AL?
With asserterror, against the label, and sometimes with no asserterror at all. The e-invoicing app has 49 asserterror statements and 37 ExpectedError assertions across 576 tests. Four tests carry a comment that says “no asserterror, on purpose”: the behaviour under test is that a call comes back empty-handed instead of raising, because an unhandled error at that point would abort the whole job queue entry, and a raise failing the test is precisely the production symptom.
The product is the counter-example, and we say so: 17 test methods, 2 live asserterror statements, and three more commented out with their assertions left running. A codebase with 174 codeunits, 316 error sites and two error-path tests has its weakest spot right there.
The Asio Services way
Error handling is a contract with the person who will read the message at six in the evening on a posting day. Ours has four clauses: every message is a label, every silent exit is a cancel, every try function ends in a trace, and every batch decides in writing what survives a failure. It is the same reasoning that keeps a large AL extension upgrade-clean twice a year, and part of why some of the AL extensions worth knowing exist to make errors better than you would by hand.
If your extension fails quietly in a job queue, or your users meet errors one field at a time, that is Business Central development work with a clear scope. Start with our clarity form and we will tell you which of the four clauses is missing.
FAQ
What is the difference between a try function and Codeunit.Run in AL?
A try function catches the error and returns false, but database changes made inside it are not rolled back. Codeunit.Run with its return value used also returns false on error and rolls back the codeunit’s changes; it commits when no error occurs, and requires a prior commit if you are already in a transaction.
What does Error(‘’) do in Business Central?
It ends execution and rolls back the transaction without showing any message. It is the idiomatic way to cancel after a user answers No to a confirmation. It should never be used to report a real failure, because nobody can find out what happened.
What are collectible errors in Business Central?
A procedure with the ErrorBehavior Collect attribute keeps running past collectible errors and gathers them, so the user can be shown every problem at once. ErrorInfo.Create() creates a collectible error by default. The feature has been available since runtime version 8.0.