Merge pull request #132 from microsoft/gggdttt-refine-self-improvement-guidance
Some checks failed
Validate knowledge index / validate-index (push) Has been cancelled
Validate AL review fixtures / validate-review-fixtures (push) Has been cancelled
Validate frontmatter and structure / validate (push) Has been cancelled

Refine self-improvement review guidance
This commit is contained in:
Wenjie Fan 2026-09-03 15:42:39 +02:00 • committed by GitHub
commit 1a5afdc0eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 296 additions and 225 deletions

View file

@ -1,31 +0,0 @@
// Demonstration-only AL. Not compiled by CI; illustrates the article.
codeunit 50241 "IsHandled Init Bad Sample"
{
procedure ApplyDiscounts(var SalesHeader: Record "Sales Header")
var
DiscountPct: Decimal;
IsHandled: Boolean;
begin
// IsHandled is never initialized before the first raise, so flow depends
// on the variable's default rather than an explicit, documented intent.
OnBeforeApplyHeaderDiscount(SalesHeader, DiscountPct, IsHandled);
if not IsHandled then
DiscountPct := 5;
// Bug: IsHandled is not reset. If the first subscriber set it true, the
// payment-discount default below is silently skipped too.
OnBeforeApplyPaymentDiscount(SalesHeader, DiscountPct, IsHandled);
if not IsHandled then
DiscountPct += 2;
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeApplyHeaderDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean)
begin
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeApplyPaymentDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean)
begin
end;
}

View file

@ -1,31 +0,0 @@
// Demonstration-only AL. Not compiled by CI; illustrates the article.
codeunit 50240 "IsHandled Init Good Sample"
{
procedure ApplyDiscounts(var SalesHeader: Record "Sales Header")
var
DiscountPct: Decimal;
IsHandled: Boolean;
begin
IsHandled := false;
OnBeforeApplyHeaderDiscount(SalesHeader, DiscountPct, IsHandled);
if not IsHandled then
DiscountPct := 5;
// Reset before reusing the same variable for the next event so a
// subscriber that handled the first raise can't suppress this one.
IsHandled := false;
OnBeforeApplyPaymentDiscount(SalesHeader, DiscountPct, IsHandled);
if not IsHandled then
DiscountPct += 2;
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeApplyHeaderDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean)
begin
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeApplyPaymentDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean)
begin
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: events
keywords: [ishandled, initialization, deterministic, onbefore, reset, integration-event, control-flow]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Initialize IsHandled to false before publishing
## Description
A routine that raises an `OnBefore…` integration event with a `var IsHandled: Boolean` parameter passes that variable in by reference, so its incoming value decides whether the default logic is skipped. A freshly declared Boolean starts as `false`, but the same variable is frequently reused to raise several events in one routine, and after the first raise it may already be `true`. Assigning `IsHandled := false;` on the line immediately before every raise makes the control flow deterministic and self-documenting, and prevents a stale `true` from silently suppressing logic the author never meant to make skippable. Generated code often reuses one `IsHandled` across several raises without resetting it.
## Best Practice
Set `IsHandled := false;` immediately before each `OnBeforeX(…, IsHandled)` raise, then guard the default logic with `if IsHandled then exit;` or `if not IsHandled then …`. Do this even when the variable was just declared: the explicit reset documents intent and stays correct if a second event raise is added to the routine later. This applies only to events that carry a `var IsHandled: Boolean`; an `OnBefore` event with no `IsHandled` parameter needs no reset.
See sample: `initialize-ishandled-to-false-before-publishing.good.al`.
## Anti Pattern
Raising `OnBeforeX(…, IsHandled)` with a variable whose value carries over from an earlier raise, so a subscriber that handled the first event unintentionally suppresses the second routine's default logic. Detection: an `IsHandled` variable passed to more than one event in a routine without an intervening `IsHandled := false;`, or any `OnBefore…` raise that passes an `IsHandled` variable without an intervening `IsHandled := false;`.
See sample: `initialize-ishandled-to-false-before-publishing.bad.al`.

View file

@ -0,0 +1,48 @@
// Demonstration-only AL. Not compiled by CI; illustrates the article.
codeunit 50241 "IsHandled Carry Over Bad Sample"
{
procedure ApplyDiscounts(var SalesHeader: Record "Sales Header")
var
DiscountPct: Decimal;
IsHandled: Boolean;
begin
OnBeforeApplyHeaderDiscount(SalesHeader, DiscountPct, IsHandled);
if not IsHandled then
DiscountPct := 5;
// Bug: execution continues when the first event set IsHandled to true,
// and that stale value is passed to a different publisher.
OnBeforeApplyPaymentDiscount(SalesHeader, DiscountPct, IsHandled);
if not IsHandled then
DiscountPct += 2;
end;
procedure ApplyLineDiscounts(var SalesLine: Record "Sales Line")
var
LineIsHandled: Boolean;
begin
if SalesLine.FindSet() then
repeat
// Bug: the local initializes only once. A subscriber that handles
// one line leaves true for every later iteration.
OnBeforeApplyLineDiscount(SalesLine, LineIsHandled);
if not LineIsHandled then
SalesLine.Validate("Line Discount %", 5);
until SalesLine.Next() = 0;
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeApplyHeaderDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean)
begin
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeApplyPaymentDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean)
begin
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeApplyLineDiscount(var SalesLine: Record "Sales Line"; var IsHandled: Boolean)
begin
end;
}

View file

@ -0,0 +1,50 @@
// Demonstration-only AL. Not compiled by CI; illustrates the article.
codeunit 50240 "IsHandled Carry Over Good Sample"
{
procedure ApplyDiscounts(var SalesHeader: Record "Sales Header")
var
DiscountPct: Decimal;
HeaderIsHandled: Boolean;
PaymentIsHandled: Boolean;
begin
// Each fresh local is false and belongs to one non-looping raise.
OnBeforeApplyHeaderDiscount(SalesHeader, DiscountPct, HeaderIsHandled);
if not HeaderIsHandled then
DiscountPct := 5;
// Handling the header event does not suppress this independent seam.
OnBeforeApplyPaymentDiscount(SalesHeader, DiscountPct, PaymentIsHandled);
if not PaymentIsHandled then
DiscountPct += 2;
end;
procedure ApplyLineDiscounts(var SalesLine: Record "Sales Line")
var
LineIsHandled: Boolean;
begin
if SalesLine.FindSet() then
repeat
// The local initializes once, so reset it per iteration; a
// subscriber that handles one line must not skip the rest.
LineIsHandled := false;
OnBeforeApplyLineDiscount(SalesLine, LineIsHandled);
if not LineIsHandled then
SalesLine.Validate("Line Discount %", 5);
until SalesLine.Next() = 0;
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeApplyHeaderDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean)
begin
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeApplyPaymentDiscount(var SalesHeader: Record "Sales Header"; var DiscountPct: Decimal; var IsHandled: Boolean)
begin
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeApplyLineDiscount(var SalesLine: Record "Sales Line"; var IsHandled: Boolean)
begin
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: events
keywords: [ishandled, carry-over, loop-iteration, onbefore, reset, integration-event, control-flow, false-positive]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Reset IsHandled before publishing only when its value can carry over
## Description
A routine that raises an `OnBefore…` integration event with a `var IsHandled: Boolean` parameter passes that variable by reference, so a pre-existing `true` can affect the following control flow. AL [automatically initializes Boolean variables to `false`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-al-variables#initialization), so a freshly declared local Boolean passed to one event exactly once per procedure invocation is already deterministic. Initialization does not repeat for each loop iteration: a local declared outside a loop can carry `true` from one iteration to the next even when the source contains only one textual event raise. Outside a loop, reaching a later raise after `if IsHandled then exit;` also proves the value is `false`, provided that early exit is semantically correct and does not skip required downstream events.
## Best Practice
Reset `IsHandled := false;` before a raise only when the value might otherwise carry over as `true`: the same variable is reused after an earlier raise without a control-flow proof that it is false, a raise is re-entered by a loop, the value comes from an input parameter, field, or global, or earlier code seeds it. Prefer separate fresh locals when independent event seams need independent handled state. A reset on a guaranteed-false fresh local used by one non-looping raise, or before a later raise reached only after a semantically valid `if IsHandled then exit;`, can be retained for readability, but its absence is not a correctness finding.
See sample: `reset-ishandled-only-when-the-value-can-carry-over.good.al`.
## Anti Pattern
Raising `OnBeforeX(…, IsHandled)` when the variable can still be `true` from an earlier raise, an earlier loop iteration, or another source, so the publisher call starts with stale state. Do not match a single non-looping raise using a fresh local Boolean, or a later raise reached only after a semantically valid `if IsHandled then exit;` proves the value is false.
See sample: `reset-ishandled-only-when-the-value-can-carry-over.bad.al`.

View file

@ -3,12 +3,24 @@ codeunit 50129 "Perf Sample CommitInLoop Bad"
procedure NormalizeCustomerNames()
var
Customer: Record Customer;
LastCustomerNo: Code[20];
ProcessedCount: Integer;
begin
Customer.SetFilter("No.", '>%1', LastCustomerNo);
if Customer.FindSet(true) then
repeat
Customer.Name := UpperCase(Customer.Name);
Customer.Modify();
Commit();
// LastCustomerNo exists only in memory, so a retry cannot exclude
// work that was already committed.
LastCustomerNo := Customer."No.";
ProcessedCount += 1;
// This still opened a FindSet over the complete remaining tail;
// periodic commits do not turn retrieval into bounded TOP X.
if ProcessedCount mod 500 = 0 then
Commit();
until Customer.Next() = 0;
end;
}

View file

@ -16,11 +16,22 @@ codeunit 50128 "Perf Sample CommitInLoop Good"
{
procedure NormalizeCustomerNames()
var
NormalizeState: Record "Perf Normalize State";
LastCustomerNo: Code[20];
begin
// The outer loop owns checkpoints; the per-row loop contains no Commit.
while NormalizeNextChunk(LastCustomerNo) do
if not NormalizeState.Get('CUSTOMER') then begin
NormalizeState.Init();
NormalizeState.Code := 'CUSTOMER';
NormalizeState.Insert();
end;
LastCustomerNo := NormalizeState."Last Customer No.";
while NormalizeNextChunk(LastCustomerNo) do begin
// Persist progress in the same transaction as the completed chunk.
NormalizeState."Last Customer No." := LastCustomerNo;
NormalizeState.Modify();
Commit();
end;
end;
local procedure NormalizeNextChunk(var LastCustomerNo: Code[20]): Boolean
@ -58,3 +69,17 @@ codeunit 50128 "Perf Sample CommitInLoop Good"
exit(true);
end;
}
table 50128 "Perf Normalize State"
{
fields
{
field(1; Code; Code[10]) { }
field(2; "Last Customer No."; Code[20]) { }
}
keys
{
key(PK; Code) { Clustered = true; }
}
}

View file

@ -13,16 +13,18 @@ application-area: [all]
## Description
Commit ends the current write transaction. Calling it inside a per-row loop produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with the platform's ability to batch write operations. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`). When the batch is too large for one transaction, the fix is not a per-row Commit but bounded checkpoints that select an exact list of at most N keys and process only those rows.
Commit ends the current write transaction. Calling it inside a per-row loop usually produces one transaction per iteration and loses the ability to roll back the whole operation atomically; it also interferes with batching. Most loops need no explicit Commit at all — AL auto-commits the enclosing code module on successful completion (see `understand-implicit-transaction-boundary.md`).
A durability checkpoint inside an outer batch loop can be valid only when the same transaction persists a progress marker or state that makes retries strictly exclude completed work, the checkpoint follows a complete business unit, and errors propagate instead of being swallowed. Restart safety and bounded retrieval are separate requirements: a persisted watermark can make retries safe, but an outer `FindSet` over the full remaining tail with periodic commits still retrieves the complete set because [`FindSet` is not implemented as `TOP X`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#get-find-findset-and-next).
## Best Practice
If the batch is large enough that a single transaction is untenable, use an ordered primary-key watermark and retrieve a bounded next-N key list. `FindSet` is optimized for reading the complete filtered set and isn't implemented as `TOP X`, so calling it over the remaining tail and breaking after N rows does not bound retrieval. The sample uses a query capped by [`TopNumberOfRows`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/query/queryinstance-topnumberofrows-method) to fill a temporary key buffer, then takes update locks and modifies only those exact keys. It does not reconstruct an inclusive first-to-last range that concurrent inserts could expand. Commit after the bounded inner loop returns and persist its last selected key as the next watermark. Use a stable key and define how a later run handles records inserted at or below an already committed watermark. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`.
If the batch is large enough that a single transaction is untenable, use an ordered primary-key watermark and retrieve a bounded next-N key list. The sample uses a query capped by [`TopNumberOfRows`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/query/queryinstance-topnumberofrows-method) to fill a temporary key buffer, then takes update locks and modifies only those exact keys. It does not reconstruct an inclusive first-to-last range that concurrent inserts could expand. Persist the last selected key in the same transaction as the completed chunk, then commit after the bounded helper returns. Use a stable key and define how a later run handles records inserted at or below an already committed watermark. Let errors escape so failed work is not recorded as complete. A `Codeunit.Run` boundary can also own a chunk when its implicit commit and error behavior fit the caller — see `codeunit-run-as-atomic-sub-operation.md`.
See sample: `avoid-commit-inside-loops.good.al`.
## Anti Pattern
Placing Commit inside `repeat ... until Next() = 0` is almost always a mistake: it is unusual for the correctness of the operation to depend on per-row commits, and the cost of starting a new transaction on every row dominates the work. A capped query that discovers only an upper key and then re-reads an inclusive key range is not exact batching either; concurrent inserts inside that range can enlarge the checkpoint.
Placing Commit inside `repeat ... until Next() = 0` without persisted progress is almost always a mistake: retries re-enter already committed work, while the cost of starting a transaction on every row dominates the operation. A progress variable held only in memory is not restart-safe. A full-tail `FindSet` with a commit every N rows is not bounded retrieval, even if a persisted watermark makes it restart-safe. A capped query that discovers only an upper key and then re-reads an inclusive key range is not exact batching either; concurrent inserts inside that range can enlarge the checkpoint.
See sample: `avoid-commit-inside-loops.bad.al`.

View file

@ -15,12 +15,12 @@ application-area: [all]
## Best Practice
Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table-extension triggers, event subscribers, global triggers, or media fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`).
Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). A visible loop for progress UX is acceptable only when evidence shows the equivalent bulk call already executes as individual operations and the loop preserves trigger and business semantics.
See sample: `prefer-modifyall-over-per-row-modify.good.al`.
## Anti Pattern
A loop that only assigns a constant and calls `Modify(false)` on a field with no validation side effects. Conversely, replacing `Validate(Field, Value); Modify(true)` with `ModifyAll(Field, Value)` is also an anti-pattern because it silently drops field validation and may drop table-trigger behavior.
A loop that only assigns a constant and calls `Modify(false)` on a field with no validation side effects or bulk fallback condition. A progress dialog alone does not exempt this loop. Conversely, replacing `Validate(Field, Value); Modify(true)` with `ModifyAll(Field, Value)` is also an anti-pattern because it silently drops field validation and may drop table-trigger behavior.
See sample: `prefer-modifyall-over-per-row-modify.bad.al`.

View file

@ -1,7 +1,7 @@
---
bc-version: [all]
domain: performance
keywords: [modifyall, deleteall, regression, triggers, media, getglobaltabletriggermask, subscriber]
keywords: [modifyall, deleteall, regression, triggers, media, security-filtering, companion-fields, subscriber, progress]
technologies: [al]
countries: [w1]
application-area: [all]
@ -11,12 +11,12 @@ application-area: [all]
## Description
`ModifyAll` and `DeleteAll` usually execute as single SQL statements, but the platform falls back to a fetch-then-row-by-row loop under specific conditions. Per the upstream guidance, the regression is triggered by any of: global database triggers defined via `GetGlobalTableTriggerMask` or `GetDatabaseTableTriggerSetup` (so that `OnDatabaseDelete`/`OnGlobalDelete` must run); event subscribers on the table's `OnBeforeDelete`/`OnAfterDelete` (for `DeleteAll`) or `OnBeforeModify`/`OnAfterModify` (for `ModifyAll`); or "adding a Media or MediaSet table field to either the table or table extension." Each of these forces the platform to materialize each affected row in AL.
`ModifyAll` and `DeleteAll` can limit SQL calls, but Microsoft documents that they [revert to individual calls](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#modifyall-and-deleteall) when the table has trigger code, related modify/delete/global/database event subscribers, active security filtering, `Media` or `MediaSet` fields, or fields added through companion tables. These conditions must be assessed from the target table and runtime context, not only from the visible bulk call.
## Best Practice
Before introducing any of the above on a table — a global trigger registration, a `Modify`/`Delete` subscriber, a media or media-set field — note every `ModifyAll`/`DeleteAll` that targets the table and assess whether the regression cost is acceptable. The upstream guidance is explicit: "There should be a very good reason for doing any of the above since they will significantly regress performance of `ModifyAll` and/or `DeleteAll`." Once a table has regressed, multiple `ModifyAll` calls each iterate the rows themselves, so consolidating to one explicit `FindSet`+`Modify` loop becomes faster than chaining several `ModifyAll` calls.
Before introducing a fallback condition, audit the `ModifyAll`/`DeleteAll` call sites that target the table and assess the regression cost. Once a bulk path already executes row by row, one explicit loop can be reasonable when it preserves the same trigger semantics and adds required per-row progress UX; consolidating several regressed bulk calls into one pass can also avoid repeated iteration. This is a narrow equivalence check, not a generic progress-dialog exemption: when no fallback condition applies, retain the bulk API.
## Anti Pattern
Adding a media field to a hot table — or subscribing to its modify/delete events from a generic logging codeunit — without auditing the bulk-write call sites. The schema change is mechanical; the performance change is invisible at the call site and only surfaces when a previously fast `ModifyAll` starts paying the per-row trigger cost in production. The mirror anti-pattern is chaining several `ModifyAll` calls on a table that has already regressed; each one re-iterates the same rows.
Adding a fallback condition to a hot table without auditing bulk-write call sites, or replacing a working bulk API with a per-row loop solely to show progress. The mirror anti-pattern is chaining several bulk calls on a table that already falls back, causing repeated row-by-row passes.

View file

@ -1,7 +1,7 @@
---
bc-version: [all]
domain: performance
keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass]
keywords: [deleteall, bulk-delete, sql, ondelete, trigger-bypass, security-filtering, media, companion-fields]
technologies: [al]
countries: [w1]
application-area: [all]
@ -13,16 +13,16 @@ application-area: [all]
## Description
`DeleteAll(false)` is eligible for a set-based SQL delete with the record variable's filters applied. It is not guaranteed to stay one statement. The base table `OnDelete` trigger is skipped, but table-extension `OnBeforeDelete` and `OnAfterDelete` triggers still run. Extension event subscribers, global delete triggers, and media fields can also require row processing. `DeleteAll(true)` runs the base table `OnDelete` trigger as well and has no performance advantage over `Delete(true)` in a loop.
`DeleteAll(false)` is eligible for a set-based SQL delete with the record variable's filters applied, but it is not guaranteed to stay one statement. Microsoft documents that `DeleteAll` [reverts to individual calls](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-al-database-methods-and-performance-on-server#modifyall-and-deleteall) when the table has trigger code, related delete/global/database event subscribers, active security filtering, `Media` or `MediaSet` fields, or fields added through companion tables. Setting `RunTrigger` to false skips the base table `OnDelete` trigger, but [table-extension `OnBeforeDelete` and `OnAfterDelete` triggers still run](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-deleteall-method#remarks).
## Best Practice
Use filtered `DeleteAll(false)` for purpose-built staging or cleanup tables only after verifying that base-table `OnDelete` logic is unnecessary and installed extensions, subscribers, global triggers, and media fields do not add required per-row behavior or regress the bulk path. If deletion requires per-row business logic, keep an explicit triggered operation instead of simulating trigger execution separately.
Use filtered `DeleteAll(false)` for purpose-built staging or cleanup tables only after verifying that base-table `OnDelete` logic is unnecessary and that trigger code, related subscribers, security filtering, media fields, and companion fields do not add required per-row behavior or regress the bulk path. If deletion requires per-row business logic, keep an explicit triggered operation instead of simulating trigger execution separately.
See sample: `use-deleteall-for-filtered-bulk-deletion.good.al`.
## Anti Pattern
Iterating with `FindSet` + `Delete(false)` to clear a filtered staging batch that has no delete logic. The reverse mistake is assuming `DeleteAll` is always one SQL statement without checking table extensions and subscribers.
Iterating with `FindSet` + `Delete(false)` to clear a filtered staging batch that has no delete logic or fallback condition. The reverse mistake is assuming `DeleteAll` is always one SQL statement without checking the documented fallback conditions.
See sample: `use-deleteall-for-filtered-bulk-deletion.bad.al`.

View file

@ -1,11 +0,0 @@
codeunit 50262 "Sample Label Scope Bad"
{
procedure LookupCustomer(CustomerNo: Code[20])
var
Customer: Record Customer;
GreetingMsg: Label 'Hello %1', Comment = '%1 = Customer Name';
begin
if Customer.Get(CustomerNo) then
Message(GreetingMsg, Customer.Name);
end;
}

View file

@ -1,13 +0,0 @@
codeunit 50263 "Sample Label Scope Good"
{
var
GreetingMsg: Label 'Hello %1', Comment = '%1 = Customer Name';
procedure LookupCustomer(CustomerNo: Code[20])
var
Customer: Record Customer;
begin
if Customer.Get(CustomerNo) then
Message(GreetingMsg, Customer.Name);
end;
}

View file

@ -1,30 +1,18 @@
---
bc-version: [all]
domain: style
keywords: [label, scope, procedure, translation, localization, xliff]
keywords: [label, scope, procedure, translation, localization, xliff, false-positive]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Declare Labels at object scope, not inside procedure `var` blocks
# Procedure-local Labels are valid
## Description
`Label` is the AL declaration that participates in the translation pipeline: the build extracts every Label declared in an object into the `.xlf` file shipped to translators, and the runtime substitutes the localized value when the object is loaded. Translation tooling discovers Labels by walking the object's top-level declarations.
Labels declared inside a procedure-local `var` block are still **compiled** as Label values, but their participation in localization is fragile: depending on the BC version, the build pipeline, and the translation toolchain in use, procedure-local Labels may be missed during XLIFF extraction, may be re-emitted with auto-generated keys that change between builds, or may not be addressable by reviewers triaging translations. The reliable, supported pattern is to declare every Label in the object's top-level `var` block.
The same rule applies to all object types that own behavior: codeunits, pages, tables, reports, queries, and their extensions. For shared messages used by multiple objects, declare the Label in the most appropriate owning object and reference it — do not duplicate the literal across procedure-scoped declarations in several places.
The AL language supports `Label` variables at both object and procedure scope. Microsoft documents the [Label data type](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-using-labels#label-data-type) without imposing an object-scope requirement, and the translation pipeline generates an XLF file containing [all labels used by the extension](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-work-with-translation-files#generating-the-xliff-file). There is no documented correctness or localization defect caused solely by declaring a Label in a procedure-local `var` block.
## Best Practice
Move every `Label` to the object's top-level `var` block. Use the appropriate suffix (`Msg`, `Err`, `Qst`, `Lbl`, `Tok`, `Txt`) on the variable name so reviewers and the translation team can see at a glance what role the string plays. Pair non-translatable strings (URLs, JSON/XML fragments, integration tokens) with `Locked = true`, as covered by `label-locked-for-non-translatable.md`.
See sample: `labels-declared-at-object-scope.good.al`.
## Anti Pattern
Declaring `Label` inside a procedure-local `var` block — `procedure Lookup() var GreetingMsg: Label 'Hello %1';` — couples the translatable string to one procedure, hides it from object-level review, and depends on a translation pipeline behavior that is not part of the AL language contract.
See sample: `labels-declared-at-object-scope.bad.al`.
Choose object scope when a Label is reused or when an established repository convention prefers central declarations; choose procedure scope when the Label belongs to one procedure. Do not report a correctness or localization finding solely because a Label is local. An explicit object-scope convention is at most a low-severity maintainability preference. This guidance applies equally to production and test apps: test code still needs localization where its strings are user-facing or translator-facing.

View file

@ -1,43 +1,75 @@
codeunit 50401 "Test UI Handlers Bad"
codeunit 50401 "Test UI Handler Proof Bad"
{
Subtype = Test;
// Several wiring mistakes, each of which fails at runtime rather than as a
// clean assertion the reviewer can read:
// * A UI call with no listed handler -> "unhandled UI" abort (the Message
// below has no handler).
// * The mirror mistake, listing a handler the path never hits, instead
// fails with "handler function was not executed".
// * A handler that hardcodes its answer and asserts inline, with no
// enqueue/dequeue -> nothing proves the RIGHT dialog fired the RIGHT
// number of times, and a failed inline assert can be swallowed by the
// calling UI operation.
[Test]
[HandlerFunctions('ConfirmHandler')]
procedure PostDocumentConfirmsAndMessages()
[HandlerFunctions('CustomerCardHandler')]
procedure PreSetBooleanDoesNotProveCustomerCardResult()
var
Customer: Record Customer;
begin
// No Initialize(): a value leaked by an earlier test corrupts this one.
RunPostingThatConfirmsAndMessages();
// No AssertEmpty(): a missing or extra dialog goes unnoticed.
LibrarySales.CreateCustomer(Customer);
ActionSucceeded := true;
Page.RunModal(Page::"Customer Card", Customer);
// This only proves a value assigned before the action stayed true.
Assert.IsTrue(ActionSucceeded, 'The customer card action failed.');
end;
local procedure RunPostingThatConfirmsAndMessages()
[Test]
[HandlerFunctions('CustomerCardHandler')]
procedure MissingMessageHandlerFailsAtRuntime()
var
Customer: Record Customer;
begin
LibrarySales.CreateCustomer(Customer);
Page.RunModal(Page::"Customer Card", Customer);
Message('Customer card closed.');
end;
[Test]
[HandlerFunctions('CustomerCardHandler,UnusedConfirmHandler')]
procedure UnreachedListedHandlerFailsAtRuntime()
var
Customer: Record Customer;
begin
LibrarySales.CreateCustomer(Customer);
Page.RunModal(Page::"Customer Card", Customer);
end;
[Test]
[HandlerFunctions('CustomerCardHandler,MandatoryNotificationHandler')]
procedure UnreachedNonoptionalNotificationHandlerFailsAtRuntime()
var
Customer: Record Customer;
begin
LibrarySales.CreateCustomer(Customer);
Page.RunModal(Page::"Customer Card", Customer);
end;
[ModalPageHandler]
procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card")
begin
// Raises a Confirm AND a Message, but only ConfirmHandler is listed:
// the Message has nothing to intercept it -> unhandled-UI runtime abort.
if Confirm('Post this document?', false) then
Message('Posting completed.');
end;
[ConfirmHandler]
procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean)
procedure UnusedConfirmHandler(Question: Text[1024]; var Reply: Boolean)
begin
// Hardcoded expectation and hardcoded reply. If the wrong dialog fires,
// this inline assert may never surface as the test's verdict.
Assert.AreEqual('Post this document?', Question, 'Wrong confirm.');
Reply := true;
end;
[SendNotificationHandler]
procedure MandatoryNotificationHandler(var TheNotification: Notification): Boolean
begin
exit(true);
end;
var
Assert: Codeunit "Library Assert";
LibrarySales: Codeunit "Library - Sales";
ActionSucceeded: Boolean;
}

View file

@ -1,57 +1,49 @@
codeunit 50400 "Test UI Handlers Good"
codeunit 50400 "Test UI Handler Capture Good"
{
Subtype = Test;
[Test]
[HandlerFunctions('ConfirmHandler,PostMessageHandler')]
procedure PostDocumentConfirmsAndMessages()
[HandlerFunctions('CustomerCardHandler')]
procedure CustomerCardShowsSelectedCustomer()
var
Customer: Record Customer;
begin
Initialize();
LibrarySales.CreateCustomer(Customer);
CapturedCustomerNo := '';
// [GIVEN] the test enqueues, in interaction order, what each handler
// will see and how it should answer: the Confirm's expected
// question plus the reply to return, then the expected Message.
LibraryVariableStorage.Enqueue('Post this document?'); // expected question (substring)
LibraryVariableStorage.Enqueue(true); // reply ConfirmHandler returns
LibraryVariableStorage.Enqueue('Posting completed.'); // expected message (substring)
Page.RunModal(Page::"Customer Card", Customer);
// [WHEN] the code under test raises the Confirm and then the Message
RunPostingThatConfirmsAndMessages();
// [THEN] every enqueued expectation was consumed exactly once
LibraryVariableStorage.AssertEmpty();
Assert.AreEqual(Customer."No.", CapturedCustomerNo, 'The customer card opened for the wrong customer.');
end;
local procedure Initialize()
[Test]
[HandlerFunctions('CustomerCardHandler,CreditLimitNotificationHandler')]
procedure CustomerCardOpensForCustomerWithinCreditLimit()
var
Customer: Record Customer;
begin
// Clear leftover values so a value leaked by an earlier test cannot
// cascade into this one.
LibraryVariableStorage.Clear();
LibrarySales.CreateCustomer(Customer);
CapturedCustomerNo := '';
Page.RunModal(Page::"Customer Card", Customer);
Assert.AreEqual(Customer."No.", CapturedCustomerNo, 'The customer card opened for the wrong customer.');
end;
local procedure RunPostingThatConfirmsAndMessages()
[ModalPageHandler]
procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card")
begin
// Stands in for the production routine that confirms, then messages.
if Confirm('Post this document?', false) then
Message('Posting completed.');
CapturedCustomerNo := CustomerCard."No.".Value();
end;
[ConfirmHandler]
procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean)
[SendNotificationHandler(true)]
procedure CreditLimitNotificationHandler(var CreditLimitNotification: Notification): Boolean
begin
// Verify the RIGHT dialog fired (substring match), then return the
// reply the test enqueued for it.
Assert.ExpectedConfirm(LibraryVariableStorage.DequeueText(), Question);
Reply := LibraryVariableStorage.DequeueBoolean();
end;
[MessageHandler]
procedure PostMessageHandler(Message: Text[1024])
begin
Assert.ExpectedMessage(LibraryVariableStorage.DequeueText(), Message);
exit(true);
end;
var
Assert: Codeunit "Library Assert";
LibraryVariableStorage: Codeunit "Library - Variable Storage";
LibrarySales: Codeunit "Library - Sales";
CapturedCustomerNo: Code[20];
}

View file

@ -1,28 +1,30 @@
---
bc-version: [all]
domain: testing
keywords: [handler, handlerfunctions, confirm, message, strmenu, variable-storage, enqueue, unhandled-ui]
keywords: [handler, handlerfunctions, confirm, message, notification, optional-handler, enqueue, capture, runmodal, unhandled-ui]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Wire and verify UI handlers with enqueue-driven expectations
# Wire UI handlers and verify meaningful outcomes
## Description
A test runs headless: there is no interactive user to answer a dialog. Every UI call the executed path raises — `Confirm`, `Message`, error dialogs, `Page.Run`/`RunModal`, `Report.Run`/`RunModal`, request pages, `StrMenu`, `Notification.Send` — must be intercepted by a handler carrying the matching attribute (`[ConfirmHandler]`, `[MessageHandler]`, `[StrMenuHandler]`, `[ModalPageHandler]`, …) and named in the method's `[HandlerFunctions(...)]`. The list is a two-sided contract: raise a UI call with no listed handler and the platform aborts with an *unhandled UI* error; list a handler the path never hits and it fails with *"handler function was not executed"*. Both are runtime failures — the test never reaches its verdict, so a reviewer sees an infrastructure error instead of a result on the behavior under test.
A test runs headless, so every UI call on the executed path must be intercepted by a matching handler named in `[HandlerFunctions(...)]`. The list is a two-sided contract: an unhandled UI call aborts the test, while Microsoft documents that [every nonoptional listed handler must execute at least once](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/attributes/devenv-handlerfunctions-attribute#remarks) or the test fails.
Getting the handler *present* is only half the job; the handler must also verify the *right* dialog fired the *right* number of times. Do that by driving handlers from the test, not by hardcoding answers inside them.
Optionality is declared, not inferred. `SendNotificationHandler` and `RecallNotificationHandler` accept a `HandlerIsOptional` argument, so `[SendNotificationHandler(true)]` may stay listed on a run that never raises the notification, while the same attribute written without that argument is nonoptional like every other handler type. Notifications are conditional by nature, so an optional notification handler is listed precisely because the scenario may or may not reach it.
Beyond that wiring guarantee, the test must verify the behavior it cares about. The appropriate pattern depends on the contract: a handler can capture concrete page state or a result and the test can assert that semantic postcondition after `RunModal`; assertions inside a handler are also supported. Queue/enqueue/dequeue and `LibraryVariableStorage.AssertEmpty` are useful when interaction order, count, text, replies, or a scripted sequence is itself part of the contract, but they are not mandatory for every handler.
## Best Practice
Make the test own the expectations and the handlers consume them. Before acting, the test `Enqueue`s — in interaction order — the expected text (a stable substring) and any reply each handler must return. The handler `Dequeue`s the expected text, verifies it with the purpose-built asserts (`Assert.ExpectedMessage`, `Assert.ExpectedConfirm`, `Assert.ExpectedStrMenu` — which match on a fragment, not the full localized caption), then `Dequeue`s and returns its reply. Finish the test body with `LibraryVariableStorage.AssertEmpty` to prove every enqueued interaction fired exactly once, and start each test with an `Initialize` that calls `LibraryVariableStorage.Clear` so a value leaked by an earlier test cannot cascade. List in `[HandlerFunctions]` precisely the handlers the scenario triggers — no superset "just in case", no subset that happens to work today.
List the handlers the scenario triggers, keep an optional notification handler listed for a notification the scenario may conditionally raise, and make each executed handler contribute meaningful evidence. For a single modal page, reset a capture variable before the action, capture a concrete value from the page in the handler, and assert the expected value after `RunModal`. For ordered or repeated interactions, let the test enqueue expectations, let handlers dequeue and verify them, clear storage during initialization, and finish with `AssertEmpty`.
See sample: `ui-handlers-in-tests.good.al`.
## Anti Pattern
Omitting a handler for a UI call the path raises (unhandled-UI abort), padding the list with a handler the path never reaches ("handler function was not executed"), or writing handlers that hardcode their answer and assert inline with no enqueue/dequeue. The last is the subtle one: nothing proves the correct dialog fired the expected number of times, and an inline assertion that fails inside a handler can be swallowed by the calling UI operation, leaving the suite green while the behavior is broken. Skipping `Initialize`/`AssertEmpty` hides both a leaked queue and a missing or extra dialog.
Omitting a handler for a UI call, listing a nonoptional handler the path never reaches, or claiming action success from a Boolean set before the action runs. A handler that only closes a page can also leave the test without a semantic assertion. Do not flag the absence of queue storage by itself; require it only when the test needs to prove interaction order, count, text, replies, or a scripted sequence. Do not flag a listed `[SendNotificationHandler(true)]` or `[RecallNotificationHandler(true)]` that the run does not reach, and never propose removing one: the entry is what keeps the test passing on the runs where the notification does fire.
See sample: `ui-handlers-in-tests.bad.al`.