Improve partner onboarding and documentation navigation (#174)
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

Lead with a complete plugin quick start and add task-oriented usage, troubleshooting, customization, and contribution guides. Preserve the broader plugin framing, correct conflicting contract guidance, support Agents folder reviews, and align repository validation. Convert existing sample references to clickable links without changing knowledge rules.

Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-09-09 17:31:03 +02:00 • committed by GitHub
parent a21edfec46
commit 2b5550c346
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
276 changed files with 1287 additions and 756 deletions

View file

@ -21,10 +21,10 @@ This rule scopes to Marketplace ISV extensions, which is what AppSourceCop valid
Own objects use the registered affix (for example `ABC Loyalty Tier`) or, when targeting BC23 or later, a qualifying namespace. Every field or action added to a standard object remains individually affixed (for example `Loyalty Points ABC` on a `Customer` tableextension).
See sample: `object-affixes-prevent-collisions.good.al`.
See sample: [`object-affixes-prevent-collisions.good.al`](object-affixes-prevent-collisions.good.al).
## Anti Pattern
An owned object with neither a qualifying namespace nor an affix, an unaffixed extension member, or the common half-measure where the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing collision protection and the field can still collide with another app.
See sample: `object-affixes-prevent-collisions.bad.al`.
See sample: [`object-affixes-prevent-collisions.bad.al`](object-affixes-prevent-collisions.bad.al).

View file

@ -17,10 +17,10 @@ An AppSource app must provide permission sets that let assigned users complete t
Trace every setup page, normal page, report, codeunit, and tabledata operation exposed by the app and cover it through assignable role permission sets composed from focused non-assignable sets. Validate setup and representative workflows as a user assigned only those app roles. Grant the minimum required operations; completeness is not a reason to use wildcards.
See sample: `permission-sets-cover-setup-and-usage-without-super.good.al`.
See sample: [`permission-sets-cover-setup-and-usage-without-super.good.al`](permission-sets-cover-setup-and-usage-without-super.good.al).
## Anti Pattern
Shipping no permission set, omitting a tabledata or execute grant used by the app's own UI, or instructing users and validators to assign `SUPER` when setup fails. Do not flag a permission-set name that differs from the app name; no such naming requirement exists.
See sample: `permission-sets-cover-setup-and-usage-without-super.bad.al`.
See sample: [`permission-sets-cover-setup-and-usage-without-super.bad.al`](permission-sets-cover-setup-and-usage-without-super.bad.al).

View file

@ -19,10 +19,10 @@ The requirement comes from AppSourceCop rule AS0011, which only runs when the ap
Choose one collision strategy for owned objects: a registered affix or a globally meaningful namespace with at least two levels. Regardless of that choice, apply the registered affix to every member added to a base or third-party object. Keep the affix configured for AppSourceCop so member validation remains deterministic. Do not raise a missing member affix against an app that does not enable AppSourceCop with a mandatory affix; there AS0011 never fires, and the app's namespace is not the reason — the absent configuration is.
See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`.
See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al).
## Anti Pattern
Using `namespace Contoso;` as though one level satisfied the AppSource alternative, or declaring `namespace Contoso.Rentals;` and then adding an unaffixed `Loyalty Points` field to `Customer` in an app that does configure a mandatory affix. The namespace distinguishes the extension's own objects; it cannot disambiguate members on Customer. The mirror-image mistake is reporting an unaffixed extension member in an app that enables no mandatory affix at all — AS0011 does not apply there, and the finding is a false positive.
See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`.
See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al).

View file

@ -17,10 +17,10 @@ Access is a decision about what you are willing to support forever. The moment a
Start everything `local` or `internal` and promote a member to `public` only when you have decided to support it as a stable contract. Expose a small, intentional surface — the supported entry point — and keep validation, posting, and helper routines `internal` for in-app reuse or `local` when single-object. Do not drop `[Scope('OnPrem')]` without intent, since that too widens the contract. Every public member is a maintenance commitment; spend them deliberately.
See sample: `choose-access-modifiers-deliberately.good.al`.
See sample: [`choose-access-modifiers-deliberately.good.al`](choose-access-modifiers-deliberately.good.al).
## Anti Pattern
Declaring every procedure `public` by default, so internal helpers like `ValidateOrder` and `PostOrder` become a de-facto API that consumers bind to and that can no longer be changed freely. Detection: an object where implementation-detail procedures carry no access modifier or are `public` without a reason to support them externally. Default them to `internal`/`local` and make only the intended entry point public.
See sample: `choose-access-modifiers-deliberately.bad.al`.
See sample: [`choose-access-modifiers-deliberately.bad.al`](choose-access-modifiers-deliberately.bad.al).

View file

@ -17,10 +17,10 @@ Deleting or renaming a published procedure (or object) in a single release is a
When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records when the method became obsolete. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed should a later release delete the method. For an object or field, use `Pending` during the warning window and `Removed` afterward.
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.good.al`.
See sample: [`deprecate-public-members-with-the-obsolete-lifecycle.good.al`](deprecate-public-members-with-the-obsolete-lifecycle.good.al).
## Anti Pattern
Renaming or deleting the published `CalcNet` procedure in place — replacing it with `CalculateNetAmount` and nothing else — so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind during a prior warning window. Do not suggest `ObsoleteState = Removed` for a method; that property belongs to supported object and element types.
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.bad.al`.
See sample: [`deprecate-public-members-with-the-obsolete-lifecycle.bad.al`](deprecate-public-members-with-the-obsolete-lifecycle.bad.al).

View file

@ -19,10 +19,10 @@ This rule governs procedures that dependents *call*. An event publisher — a pr
Treat a published signature as frozen. When new behavior needs more inputs, add a new procedure or overload alongside the original — for example a `CalculateDiscountWithRate(Amount; Rate)` next to the unchanged `CalculateDiscount(Amount)` — and let the old one delegate to the new one. Existing callers keep compiling; new callers opt into the richer entry point. Naming an unnamed return value is the one in-place change that is always safe.
See sample: `do-not-change-published-procedure-signatures.good.al`.
See sample: [`do-not-change-published-procedure-signatures.good.al`](do-not-change-published-procedure-signatures.good.al).
## Anti Pattern
Editing the existing public procedure's parameter list — here, adding a `Rate` parameter to `CalculateDiscount` — so every dependent extension that called the old form fails to compile. Detection: a parameter added, removed, reordered, retyped, or flipped to/from `var`, or a changed return type, on any non-`local` procedure that already shipped. Add a new overload instead. Exclude event publishers whose only change is an added parameter: subscribers bind by parameter name, not position, so that edit is additive and reporting it here is a false positive.
See sample: `do-not-change-published-procedure-signatures.bad.al`.
See sample: [`do-not-change-published-procedure-signatures.bad.al`](do-not-change-published-procedure-signatures.bad.al).

View file

@ -17,10 +17,10 @@ Every member you make publicly reachable becomes a contract you must keep — an
Keep secrets in `internal` or `local` members, and prefer the `SecretText` type so the value cannot be read back or logged. Where callers genuinely need a credential, pass it inward (a setter) rather than handing it outward (a getter). Public API should return only non-sensitive data — a masked reference, a status, a business identifier — never the raw secret. Treat each public member as a lasting commitment and keep the security-sensitive surface as small as possible.
See sample: `do-not-expose-sensitive-data-through-public-api.good.al`.
See sample: [`do-not-expose-sensitive-data-through-public-api.good.al`](do-not-expose-sensitive-data-through-public-api.good.al).
## Anti Pattern
A public `GetAccessToken()` that returns the raw token (or an event parameter carrying a credential to all subscribers), turning a secret into a de-facto public API any dependent can consume. Detection: a non-`local` procedure, event parameter, or global variable that surfaces a token, password, key, or other credential. Keep the secret internal and expose only non-sensitive data.
See sample: `do-not-expose-sensitive-data-through-public-api.bad.al`.
See sample: [`do-not-expose-sensitive-data-through-public-api.bad.al`](do-not-expose-sensitive-data-through-public-api.bad.al).

View file

@ -17,10 +17,10 @@ A member carrying `[Obsolete]`, or wrapped in a `#if not CLEANxx` conditional-co
Leave obsolete members exactly as they are and implement against the current, supported replacement. New logic — a surcharge calculation, an event publisher, a hook — belongs on the live API (`GetUnitPrice`), never inside the deprecated `GetPrice` or behind a `#if not CLEAN25` guard. If the replacement does not yet exist, create it as a first-class member and build there. The obsolete code should only shrink over time, not accrete new behavior.
See sample: `do-not-modify-code-already-marked-obsolete.good.al`.
See sample: [`do-not-modify-code-already-marked-obsolete.good.al`](do-not-modify-code-already-marked-obsolete.good.al).
## Anti Pattern
Adding a surcharge calculation inside the `[Obsolete]` `GetPrice` procedure, or behind a `#if not CLEAN25` block, so the new behavior is wired to code that will be removed when `CLEAN25` is enabled. Detection: new statements, event declarations, or dependencies introduced inside an `[Obsolete]`-marked member or a `#if not CLEANxx` region. Move the logic onto the supported replacement instead.
See sample: `do-not-modify-code-already-marked-obsolete.bad.al`.
See sample: [`do-not-modify-code-already-marked-obsolete.bad.al`](do-not-modify-code-already-marked-obsolete.bad.al).

View file

@ -17,10 +17,10 @@ AL resolves an object by namespace and name. Once an app ships and dependent ext
Choose a globally meaningful namespace before first publication and keep it stable. Add new functional areas beneath that structure without moving existing published objects. If an identity must move, use the platform's supported move/obsoletion lifecycle rather than a source-only namespace rename.
See sample: `namespace-is-part-of-published-object-identity.good.al`.
See sample: [`namespace-is-part-of-published-object-identity.good.al`](namespace-is-part-of-published-object-identity.good.al).
## Anti Pattern
Changing `namespace Contoso.Rentals;` to `namespace Contoso.RentalManagement;` as a cleanup while leaving the object name and ID untouched. Every dependent `using` directive and qualified reference targets the old identity and stops compiling.
See sample: `namespace-is-part-of-published-object-identity.bad.al`.
See sample: [`namespace-is-part-of-published-object-identity.bad.al`](namespace-is-part-of-published-object-identity.bad.al).

View file

@ -17,10 +17,10 @@ A shipped table field carries both a source-level contract and persisted data. R
Keep the old field's ID, name, and type unchanged. Add the replacement as a separate field under an unused ID, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` recording the obsoletion version. Keep the old field readable so an upgrade codeunit can copy its data during the deprecation window. Move it to `ObsoleteState = Removed` only in a later release, after the window has passed and data has migrated.
See sample: `obsolete-table-fields-instead-of-deleting-them.good.al`.
See sample: [`obsolete-table-fields-instead-of-deleting-them.good.al`](obsolete-table-fields-instead-of-deleting-them.good.al).
## Anti Pattern
Renaming published `Email` to `Contact Email` with the same ID violates the compatibility contract and AS0005, even though the retained ID does not itself imply a fresh empty column. Deleting `Email` or changing its ID additionally risks losing its stored values. Detection: any previously shipped field whose name changes at the same ID, or whose original ID disappears without the unchanged field being retained as `Pending` and its data migrated to a separate replacement field.
See sample: `obsolete-table-fields-instead-of-deleting-them.bad.al`.
See sample: [`obsolete-table-fields-instead-of-deleting-them.bad.al`](obsolete-table-fields-instead-of-deleting-them.bad.al).

View file

@ -19,10 +19,10 @@ Putting the block check inside the master's own `OnInsert`/`OnModify` does nothi
The referencing line validates `Master.TestField(Blocked, false)` in `OnValidate` of the reference field and re-checks before posting. The master table stays logic-free on `Blocked`.
See sample: `check-blocked-in-referencing-code-not-in-master.good.al`.
See sample: [`check-blocked-in-referencing-code-not-in-master.good.al`](check-blocked-in-referencing-code-not-in-master.good.al).
## Anti Pattern
The block check sits in the master's own `OnModify`/`OnInsert` (so referencing and posting proceed unchecked), or there is no check at all on the referencing side.
See sample: `check-blocked-in-referencing-code-not-in-master.bad.al`.
See sample: [`check-blocked-in-referencing-code-not-in-master.bad.al`](check-blocked-in-referencing-code-not-in-master.bad.al).

View file

@ -19,10 +19,10 @@ This is not an `Integer` `AutoIncrement` key, a GUID, or the `SystemId`. Those a
`No.` `Code[20]` is the sole primary key; a non-editable `No. Series` `Code[20]` field records the source series. `OnInsert` checks `if "No." = ''`, reads the setup table, `TestField`s the configured series, stores it in `No. Series`, and assigns `No.` from the series.
See sample: `master-table-no-from-number-series-in-oninsert.good.al`.
See sample: [`master-table-no-from-number-series-in-oninsert.good.al`](master-table-no-from-number-series-in-oninsert.good.al).
## Anti Pattern
An `Integer` `AutoIncrement` (or GUID / `SystemId`) primary key used as the business key, with no `OnInsert` number assignment. Records get an opaque identifier no user can reference, and the master no longer participates in the standard numbering and manual-entry behavior every other BC master follows.
See sample: `master-table-no-from-number-series-in-oninsert.bad.al`.
See sample: [`master-table-no-from-number-series-in-oninsert.bad.al`](master-table-no-from-number-series-in-oninsert.bad.al).

View file

@ -25,7 +25,7 @@ See also `validate-table-relation-false-suppresses-rename-propagation.md` for th
The owning table implements `OnDelete` and deletes its dependents there, filtered on the foreign key. Declare `Permissions = tabledata <dependent> = rd` on the owning table — granting delete rights only on the parent is a common miss that makes the trigger fail for a non-`SUPER` user. This mirrors the base application, where every header table deletes its own lines.
See sample: `owning-table-must-delete-dependents-in-ondelete.good.al`.
See sample: [`owning-table-must-delete-dependents-in-ondelete.good.al`](owning-table-must-delete-dependents-in-ondelete.good.al).
## Anti Pattern
@ -33,4 +33,4 @@ A parent table with dependent rows and no `OnDelete` trigger, where the dependen
Detection signal: a table declares `TableRelation` to table X, and table X has no `OnDelete` trigger. Whether a delete path currently exists in the UI is irrelevant to the finding.
See sample: `owning-table-must-delete-dependents-in-ondelete.bad.al`.
See sample: [`owning-table-must-delete-dependents-in-ondelete.bad.al`](owning-table-must-delete-dependents-in-ondelete.bad.al).

View file

@ -19,10 +19,10 @@ The reason is a BC-specific trap: renaming a record changes its primary key and
Both `OnModify` and `OnRename` set `"Last Date Modified" := Today();`, and the field is declared `Editable = false` so only the triggers maintain it.
See sample: `set-last-date-modified-in-onmodify-and-onrename.good.al`.
See sample: [`set-last-date-modified-in-onmodify-and-onrename.good.al`](set-last-date-modified-in-onmodify-and-onrename.good.al).
## Anti Pattern
Only `OnModify` assigns `Last Date Modified`. After a rename the value is stale, and any process that trusts it to detect changes misses the record.
See sample: `set-last-date-modified-in-onmodify-and-onrename.bad.al`.
See sample: [`set-last-date-modified-in-onmodify-and-onrename.bad.al`](set-last-date-modified-in-onmodify-and-onrename.bad.al).

View file

@ -19,10 +19,10 @@ The setup **card** page enforces the singleton: `InsertAllowed = false` and `Del
`Primary Key` `Code[10]` is the sole key; the setup is surfaced through a Card page with `InsertAllowed = false`, `DeleteAllowed = false`, and an open-time guard that inserts the blank row if it is missing.
See sample: `setup-table-is-a-singleton.good.al`.
See sample: [`setup-table-is-a-singleton.good.al`](setup-table-is-a-singleton.good.al).
## Anti Pattern
An `Integer` / `AutoIncrement` key, a page that allows insert or delete, or a List page over the setup table. Any of these lets the table hold zero or many rows, so "the setup" becomes ambiguous and `Get()` may fail or read the wrong record.
See sample: `setup-table-is-a-singleton.bad.al`.
See sample: [`setup-table-is-a-singleton.bad.al`](setup-table-is-a-singleton.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
When sharing media between different tables, iterate the source `MediaSet` and call `Target.MediaSetField.Insert(Source.MediaSetField.Item(Index))`, then modify the target record. Direct field assignment is safe only when source and target are the same record subtype and use the same field ID. This concern is about reference/delete integrity, not the separate performance cost of `ModifyAll` on tables with media fields.
See sample: `share-mediaset-items-with-insert-not-field-assignment.good.al`.
See sample: [`share-mediaset-items-with-insert-not-field-assignment.good.al`](share-mediaset-items-with-insert-not-field-assignment.good.al).
## Anti Pattern
`Target.Picture := Source.Picture;` where the two variables refer to different table types or different media-field IDs. The code copies an opaque ID, but the platform does not know that two independent fields now share the media object.
See sample: `share-mediaset-items-with-insert-not-field-assignment.bad.al`.
See sample: [`share-mediaset-items-with-insert-not-field-assignment.bad.al`](share-mediaset-items-with-insert-not-field-assignment.bad.al).

View file

@ -17,10 +17,10 @@ A `tableextension` can add to an existing `TableRelation`, but the combined rela
When a relation is designed to follow an extensible enum, express the base cases as conditional branches and leave no unconditional catch-all ahead of future extension branches. An enum extension can then append a condition for its new value. When extending a field you do not own, inspect the original `TableRelation`; do not claim that an appended condition overrides an unconditional relation.
See sample: `table-relation-extensions-are-additive-and-top-down.good.al`.
See sample: [`table-relation-extensions-are-additive-and-top-down.good.al`](table-relation-extensions-are-additive-and-top-down.good.al).
## Anti Pattern
A base field has an unconditional `TableRelation = Customer;` and a `tableextension` adds `if (Type = const(Resource)) Resource`. The original unconditional branch always wins, so the new enum value still validates and looks up against Customer. The concern is evaluation order, not `ValidateTableRelation`; free-form input is covered separately by security guidance.
See sample: `table-relation-extensions-are-additive-and-top-down.bad.al`.
See sample: [`table-relation-extensions-are-additive-and-top-down.bad.al`](table-relation-extensions-are-additive-and-top-down.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
Use `TransferFields(Source)` only when every field the destination requires, including primary key fields, is guaranteed to share a matching field number and type with the source; this form defaults `InitPrimaryKeyFields` to `true`. Fields with no matching field number, and fields whose types differ across extensions, are skipped regardless of `SkipFieldsNotMatchingType` — that parameter only governs same-extension type mismatches. If the destination depends on a field that falls into either case, map and validate it explicitly in code rather than relying on `TransferFields` to catch the gap. Use `SkipFieldsNotMatchingType = true` only when skipping same-extension type mismatches is an intentional, documented part of the transfer contract.
See sample: `transferfields-skip-type-mismatch-can-drop-data.good.al`.
See sample: [`transferfields-skip-type-mismatch-can-drop-data.good.al`](transferfields-skip-type-mismatch-can-drop-data.good.al).
## Anti Pattern
Using `TransferFields(Source, InitPrimaryKeyFields, true)` as a generic way to make two evolving table schemas transfer without errors, when the destination depends on every required source field being copied. A type change on either table can turn a previously transferred field into a silently skipped one without making the transfer itself fail.
See sample: `transferfields-skip-type-mismatch-can-drop-data.bad.al`.
See sample: [`transferfields-skip-type-mismatch-can-drop-data.bad.al`](transferfields-skip-type-mismatch-can-drop-data.bad.al).

View file

@ -19,10 +19,10 @@ LLMs reproduce the legacy `NoSeriesManagement` pattern because it dominates pre-
`OnInsert` assigns the number with `NoSeries.GetNextNo("No. Series")` where `NoSeries` is `Codeunit "No. Series"`. The `No.` field's `OnValidate` guards manual entry by calling `NoSeries.IsManual(...)` (or `TestManual`) before clearing `No. Series`.
See sample: `use-no-series-codeunit-not-noseriesmanagement.good.al`.
See sample: [`use-no-series-codeunit-not-noseriesmanagement.good.al`](use-no-series-codeunit-not-noseriesmanagement.good.al).
## Anti Pattern
`NoSeriesMgt.InitSeries(...)` for assignment and `NoSeriesMgt.TestManual(...)` for the manual check, where `NoSeriesMgt` is `Codeunit NoSeriesManagement`. Both are obsolete-pending and emit compiler warnings.
See sample: `use-no-series-codeunit-not-noseriesmanagement.bad.al`.
See sample: [`use-no-series-codeunit-not-noseriesmanagement.bad.al`](use-no-series-codeunit-not-noseriesmanagement.bad.al).

View file

@ -27,7 +27,7 @@ See also `owning-table-must-delete-dependents-in-ondelete.md` for the delete hal
Leave `ValidateTableRelation` at its default wherever the stored value must stay correct across a rename. When it must be disabled, or when the relationship cannot be expressed as a `TableRelation` at all, the table owning the referenced key carries an explicit `OnRename` that repoints the dependents itself.
See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al`.
See sample: [`validate-table-relation-false-suppresses-rename-propagation.good.al`](validate-table-relation-false-suppresses-rename-propagation.good.al).
## Anti Pattern
@ -35,4 +35,4 @@ See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al
Detection signal: any `ValidateTableRelation = false` on a field that also declares a `TableRelation`. Ask what repoints the value when the target is renamed; if the answer is "the platform", the finding stands.
See sample: `validate-table-relation-false-suppresses-rename-propagation.bad.al`.
See sample: [`validate-table-relation-false-suppresses-rename-propagation.bad.al`](validate-table-relation-false-suppresses-rename-propagation.bad.al).

View file

@ -25,7 +25,7 @@ See also `validate-table-relation-false-suppresses-rename-propagation.md`, which
Use `xRec` for the previous key in `OnRename`, and for the record being removed in `OnDelete`. In `OnModify`, obtain the before-image by re-reading the stored row rather than trusting `xRec`, so the logic behaves identically whether a page, a job queue or an API drove the write.
See sample: `xrec-is-a-before-image-only-in-some-triggers.good.al`.
See sample: [`xrec-is-a-before-image-only-in-some-triggers.good.al`](xrec-is-a-before-image-only-in-some-triggers.good.al).
## Anti Pattern
@ -33,4 +33,4 @@ Comparing `Rec` against `xRec` inside `OnModify` (or `OnInsert`) to detect a cha
Detection signal: any read of `xRec` inside `OnModify` or `OnInsert`. Treat "but it works when I test it on the page" as confirmation of the defect rather than a refutation.
See sample: `xrec-is-a-before-image-only-in-some-triggers.bad.al`.
See sample: [`xrec-is-a-before-image-only-in-some-triggers.bad.al`](xrec-is-a-before-image-only-in-some-triggers.bad.al).

View file

@ -17,10 +17,10 @@ By default a procedure stops on the first `Error`, so a user fixing ten bad rows
Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest — typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()`, retrieve and clear the list with `GetCollectedErrors(true)`, and fail the operation with the collected messages. The sample intentionally produces a text aggregate and does not claim to retain record/field metadata in the final error. If that metadata is needed, map each `ErrorInfo` to a custom error UI before clearing, following the Microsoft Learn pattern. Do not replace validation failure with `Message`: clearing collected errors suppresses the platform failure, so the custom handler must still block the invalid operation.
See sample: `collect-validation-errors-with-errorbehavior.good.al`.
See sample: [`collect-validation-errors-with-errorbehavior.good.al`](collect-validation-errors-with-errorbehavior.good.al).
## Anti Pattern
Three shapes signal trouble. Hand-rolled accumulation reimplements collection and prevents the handler from receiving individual `ErrorInfo` values. A `Collect` procedure that never handles the collection falls back to the concatenated platform dialog. Finally, code that calls parameterless `GetCollectedErrors()`, assumes it cleared the list, and only shows a `Message` can both leave the errors collected and allow invalid processing to continue.
See sample: `collect-validation-errors-with-errorbehavior.bad.al`.
See sample: [`collect-validation-errors-with-errorbehavior.bad.al`](collect-validation-errors-with-errorbehavior.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
Reserve `ErrorType::Internal` for errors the user cannot act on: corrupted internal state, an unreachable branch, a contract a caller violated. Set a precise, detail-rich `Message` for telemetry, raise it via `Error(ErrorInfo)`, and let the platform show the user a generic dialog. Keep `ErrorType::Client` (or a plain `Error`) for failures the user is expected to read and resolve — validation messages, missing setup, business-rule violations. The test is simple: if the message only makes sense to a developer, mark it `Internal`.
See sample: `errortype-internal-vs-client-for-diagnostics.good.al`.
See sample: [`errortype-internal-vs-client-for-diagnostics.good.al`](errortype-internal-vs-client-for-diagnostics.good.al).
## Anti Pattern
Raising an internal failure with a plain `Error('Unexpected state: ledger bucket %1 not initialized', BucketId)`. The user is shown a technical message they can do nothing about, and the signal is buried in a generic error rather than carried as structured telemetry detail. Detection: an `Error` whose wording targets a developer ("unexpected", "should not happen", raw internal identifiers) raised with default `Client` visibility instead of an `ErrorInfo` marked `ErrorType::Internal`.
See sample: `errortype-internal-vs-client-for-diagnostics.bad.al`.
See sample: [`errortype-internal-vs-client-for-diagnostics.bad.al`](errortype-internal-vs-client-for-diagnostics.bad.al).

View file

@ -14,9 +14,9 @@ application-area: [all]
## Best Practice
For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you.
See sample: `fielderror-default-message-logic.good.al`.
See sample: [`fielderror-default-message-logic.good.al`](fielderror-default-message-logic.good.al).
## Anti Pattern
Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` — capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context.
See sample: `fielderror-default-message-logic.bad.al`.
See sample: [`fielderror-default-message-logic.bad.al`](fielderror-default-message-logic.bad.al).

View file

@ -16,9 +16,9 @@ Use `TestField` when the condition is a simple presence-or-equality check on a s
A page action's `OnAction` trigger is a different case: a page action is only invocable through its own UI control, so when the action's `Enabled` property is already bound to the same condition the trigger would otherwise `TestField`, the control cannot be clicked while the field is blank and the field can never reach the trigger empty. Adding a `TestField` there is redundant defensive code, not a missing check — flag it only when the trigger can run through a path `Enabled` does not cover (a shared procedure, an API, or a condition broader than what gates the action).
See sample: `fielderror-vs-testfield.good.al`.
See sample: [`fielderror-vs-testfield.good.al`](fielderror-vs-testfield.good.al).
## Anti Pattern
Calling `FieldError` to "test" a field — placing it on a path that is reached unconditionally and expecting it to validate — terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality.
See sample: `fielderror-vs-testfield.bad.al`.
See sample: [`fielderror-vs-testfield.bad.al`](fielderror-vs-testfield.bad.al).

View file

@ -17,13 +17,13 @@ A procedure marked `[TryFunction]` catches errors only when the caller uses its
Consume the result directly: assign it to a Boolean or use the call in an `if` condition. Handle `false` immediately while the last-error state still describes that failure.
See sample: `ignored-tryfunction-return-disables-try-semantics.good.al`.
See sample: [`ignored-tryfunction-return-disables-try-semantics.good.al`](ignored-tryfunction-return-disables-try-semantics.good.al).
## Anti Pattern
Calling a `[TryFunction]` procedure as a standalone statement and assuming the attribute suppresses its errors. The call has ordinary error semantics because its Boolean result is ignored.
See sample: `ignored-tryfunction-return-disables-try-semantics.bad.al`.
See sample: [`ignored-tryfunction-return-disables-try-semantics.bad.al`](ignored-tryfunction-return-disables-try-semantics.bad.al).
## See also

View file

@ -17,10 +17,10 @@ A plain `Error('text')` ends the operation with a dead-end dialog: the user read
Build an `ErrorInfo`, set `Title`, `Message`, and `DetailedMessage`, then attach the action that matches the situation. For a Fix-it, call `AddAction(Caption, Codeunit::Handler, 'MethodName')` where the handler method (which receives the `ErrorInfo`) applies the known-good value; phrase the caption as "Set value to …". For a Show-it, set `PageNo := Page::"…"`, set `RecordId` so navigation opens the right record, and call `AddNavigationAction('Show …')`. Raise it with `Error(ErrorInfo)`. Reserve recommended actions for cases where the solution is genuinely known and the user has permission to apply it.
See sample: `prefer-errorinfo-for-actionable-errors.good.al`.
See sample: [`prefer-errorinfo-for-actionable-errors.good.al`](prefer-errorinfo-for-actionable-errors.good.al).
## Anti Pattern
Surfacing a recoverable validation failure with `Error('You cannot invoice more than %1 units.', MaxQty)` and nothing else. The user is blocked with no offered remedy even though the code knows the maximum and could set it. The detection signal: an `Error` call in a validation or posting path whose message names a specific correct value or a specific related page, with no surrounding `ErrorInfo`, `AddAction`, or `AddNavigationAction`. Replace it with an `ErrorInfo` that carries the corresponding Fix-it or Show-it action.
See sample: `prefer-errorinfo-for-actionable-errors.bad.al`.
See sample: [`prefer-errorinfo-for-actionable-errors.bad.al`](prefer-errorinfo-for-actionable-errors.bad.al).

View file

@ -17,10 +17,10 @@ Event subscribers bind publisher parameters by name and can omit parameters they
Add a parameter directly only when the shipped event publisher is `local` or `internal`. Place it where the signature is clearest; existing subscribers continue binding the parameters they name. For a public event, keep the original publisher unchanged and introduce a new event with the expanded contract.
See sample: `add-new-event-parameters-at-the-end.good.al`.
See sample: [`add-new-event-parameters-at-the-end.good.al`](add-new-event-parameters-at-the-end.good.al).
## Anti Pattern
Appending a parameter to a public event and assuming its position makes the change compatible. Existing external callers still lack the new required argument. Conversely, do not flag a parameter inserted among existing parameters on a `local` or `internal` Business or Integration event merely because it was not appended.
See sample: `add-new-event-parameters-at-the-end.bad.al`.
See sample: [`add-new-event-parameters-at-the-end.bad.al`](add-new-event-parameters-at-the-end.bad.al).

View file

@ -17,10 +17,10 @@ Passing `RecordRef` or `xRec` as event parameters weakens the contract. A `Recor
Give events concrete record types and explicit values, such as `(SalesLine: Record "Sales Line"; PreviousQuantity: Decimal)`, instead of a `RecordRef` or an `xRec` parameter. Subscribers then get type safety, field access, and an unambiguous contract.
See sample: `avoid-loosely-typed-event-parameters.good.al`.
See sample: [`avoid-loosely-typed-event-parameters.good.al`](avoid-loosely-typed-event-parameters.good.al).
## Anti Pattern
Event parameters typed as `RecordRef` (no table type) or an `xRec`-style "previous record" (ambiguous, possibly stale) without strong justification. Detection: an event signature containing a `RecordRef` parameter, or a passed-through `xRec` record, where a concrete typed record and explicit values would serve.
See sample: `avoid-loosely-typed-event-parameters.bad.al`.
See sample: [`avoid-loosely-typed-event-parameters.bad.al`](avoid-loosely-typed-event-parameters.bad.al).

View file

@ -17,10 +17,10 @@ A `TryFunction` catches all errors — including errors thrown by event subscrib
Raise the integration event before entering the TryFunction scope. The event and its subscribers execute outside the error boundary, so subscriber errors propagate normally to the caller. Move only the operation that genuinely needs error isolation (such as an HTTP call or a posting step) inside the TryFunction.
See sample: `avoid-raising-events-inside-try-functions.good.al`.
See sample: [`avoid-raising-events-inside-try-functions.good.al`](avoid-raising-events-inside-try-functions.good.al).
## Anti Pattern
Raising an integration event inside a TryFunction body. Subscriber failures are caught and discarded by the TryFunction. The subscriber contract — that a subscriber can signal failure to the caller — is silently broken.
See sample: `avoid-raising-events-inside-try-functions.bad.al`.
See sample: [`avoid-raising-events-inside-try-functions.bad.al`](avoid-raising-events-inside-try-functions.bad.al).

View file

@ -17,10 +17,10 @@ An `[EventSubscriber]` codeunit is static by default (`EventSubscriberInstance =
Use a static subscriber for behaviour that genuinely applies all the time. For anything scoped, mark the codeunit `EventSubscriberInstance = Manual`, call `BindSubscription(SubscriberInstance)` at the start of the scope and `UnbindSubscription(SubscriberInstance)` at the end. A manual subscriber held only in a local variable unbinds automatically when that variable leaves scope, which suits test setup/teardown; a binding you intend to outlive a single call must be unbound explicitly. Keep subscriber methods `local` per CodeCop AA0207.
See sample: `choose-static-vs-manual-subscribers-deliberately.good.al`.
See sample: [`choose-static-vs-manual-subscribers-deliberately.good.al`](choose-static-vs-manual-subscribers-deliberately.good.al).
## Anti Pattern
Two shapes. First, a static subscriber used for behaviour that should be scoped — an always-on side effect (sending mail, writing extra records) that now fires for every event in every session and test with no way to disable it. Second, a manual subscriber that is bound with `BindSubscription` and never unbound: when the instance is held beyond the intended scope (for example on a `SingleInstance` codeunit), the binding leaks for the whole session and later unrelated operations keep hitting it. Detection: scoped side effects on a static subscriber, or a `BindSubscription` call with no matching `UnbindSubscription` and no scope that releases the instance.
See sample: `choose-static-vs-manual-subscribers-deliberately.bad.al`.
See sample: [`choose-static-vs-manual-subscribers-deliberately.bad.al`](choose-static-vs-manual-subscribers-deliberately.bad.al).

View file

@ -29,7 +29,7 @@ Give an event publisher the narrowest access modifier that still lets the code o
Subscribers are unaffected by any of these choices. A non-public publisher also keeps the freedom to add a parameter later, which a public publisher gives up — see `add-new-event-parameters-at-the-end`.
See sample: `declare-event-publishers-local-or-internal.good.al`.
See sample: [`declare-event-publishers-local-or-internal.good.al`](declare-event-publishers-local-or-internal.good.al).
## Anti Pattern
@ -39,4 +39,4 @@ Detection: an `[IntegrationEvent]` or `[BusinessEvent]` publisher that is public
The mirror-image anti-pattern belongs to the reviewer, human or agent: recommending that a publisher be made public so extensions can subscribe, or reporting a `local`/`internal` publisher as unreachable dead code. Both readings mistake raising for subscribing. Neither should be raised as a finding.
See sample: `declare-event-publishers-local-or-internal.bad.al`.
See sample: [`declare-event-publishers-local-or-internal.bad.al`](declare-event-publishers-local-or-internal.bad.al).

View file

@ -17,10 +17,10 @@ Adding a `var IsHandled: Boolean` parameter to an event that already shipped wit
Keep the existing event as-is and add a separate `OnBeforeX(…; var IsHandled: Boolean)` before the logic you want to make overridable. Two events with distinct, stable contracts are safer than one event whose meaning and signature were changed under its subscribers.
See sample: `do-not-add-ishandled-to-an-existing-event.good.al`.
See sample: [`do-not-add-ishandled-to-an-existing-event.good.al`](do-not-add-ishandled-to-an-existing-event.good.al).
## Anti Pattern
Mutating a shipped event — for example adding `var IsHandled` to `OnAfterCalculateTotal` — to retrofit override behaviour, which overloads the event's meaning and undermines existing subscribers. Detection: an `IsHandled` parameter added to a pre-existing event signature rather than introduced through a new dedicated `OnBefore` publisher.
See sample: `do-not-add-ishandled-to-an-existing-event.bad.al`.
See sample: [`do-not-add-ishandled-to-an-existing-event.bad.al`](do-not-add-ishandled-to-an-existing-event.bad.al).

View file

@ -17,10 +17,10 @@ The IsHandled override pattern lets a subscriber skip the guarded code entirely.
Scope IsHandled to a safe value-calculation block and run the critical operations unconditionally afterwards; or expose a positive `OnAfter…` event for subscribers to adjust results, rather than a bypass around the commit.
See sample: `do-not-bypass-critical-operations-with-ishandled.good.al`.
See sample: [`do-not-bypass-critical-operations-with-ishandled.good.al`](do-not-bypass-critical-operations-with-ishandled.good.al).
## Anti Pattern
An `OnBefore…` IsHandled guard wrapping a posting or ledger routine — `if IsHandled then exit;` around the code that creates ledger entries and updates document status — letting subscribers skip the commit. Detection: an `if IsHandled then exit;` whose skipped body performs posting, ledger writes, number-series consumption, or integrity and permission validation.
See sample: `do-not-bypass-critical-operations-with-ishandled.bad.al`.
See sample: [`do-not-bypass-critical-operations-with-ishandled.bad.al`](do-not-bypass-critical-operations-with-ishandled.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
Keep every available attribute argument exactly as shipped. If new subscribers need different sender/global exposure, publish a new event with the desired flags. Apply the same rule to `Isolated` only on BC20 or later, where that argument exists. Raise both events while the original contract is supported, and choose preferred flags only when designing a new event.
See sample: `do-not-change-shipped-event-attribute-flags.good.al`.
See sample: [`do-not-change-shipped-event-attribute-flags.good.al`](do-not-change-shipped-event-attribute-flags.good.al).
## Anti Pattern
Changing a shipped event's `IncludeSender` or `GlobalVarAccess` to modernize its design, including replacing `IncludeSender` with an explicit parameter. On BC20 or later, adding, removing, or toggling `Isolated` is equally contract-significant. Even a change that leaves old subscribers compiling can alter observable execution or exposure; version the event instead.
See sample: `do-not-change-shipped-event-attribute-flags.bad.al`.
See sample: [`do-not-change-shipped-event-attribute-flags.bad.al`](do-not-change-shipped-event-attribute-flags.bad.al).

View file

@ -17,10 +17,10 @@ Raising an event on every iteration of a loop multiplies the cost of every subsc
Raise `OnBeforeProcessLines` before the loop and `OnAfterProcessLines` after it, outside the `repeat … until`, so each subscriber runs once per batch rather than once per row. Give those events the record or filters they need to operate on the whole set.
See sample: `do-not-publish-events-inside-loops.good.al`.
See sample: [`do-not-publish-events-inside-loops.good.al`](do-not-publish-events-inside-loops.good.al).
## Anti Pattern
An event raised inside the loop body, fired once per iteration, so subscriber cost scales with the row count and large batches slow down or time out. Detection: an `OnBefore…`/`OnAfter…`/`On…` raise located between `repeat` and `until` in a record loop.
See sample: `do-not-publish-events-inside-loops.bad.al`.
See sample: [`do-not-publish-events-inside-loops.bad.al`](do-not-publish-events-inside-loops.bad.al).

View file

@ -25,7 +25,7 @@ Publish the context as a query and let the binding itself be the state. One proc
Bind a fresh instance per run rather than reusing one: the platform refuses to bind the same instance twice but accepts several instances of the same codeunit, so nesting and re-entrancy need no counter. The binding is session-scoped, so work the process starts in another session — a background session, a page background task, a job queue entry — cannot see it; pass the context explicitly there.
See sample: `expose-process-context-via-manually-bound-flag.good.al`.
See sample: [`expose-process-context-via-manually-bound-flag.good.al`](expose-process-context-via-manually-bound-flag.good.al).
## Anti Pattern
@ -37,4 +37,4 @@ Second, the context kept private: the driving app arranges its own marker — ty
The mirror-image anti-pattern belongs to the reviewer: flagging the `BindSubscription` here as a leaked binding because no `UnbindSubscription` follows it. Scope release is the mechanism, not an omission — see `microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md`, whose leak case is an instance parked on a `SingleInstance` global that never leaves scope.
See sample: `expose-process-context-via-manually-bound-flag.bad.al`.
See sample: [`expose-process-context-via-manually-bound-flag.bad.al`](expose-process-context-via-manually-bound-flag.bad.al).

View file

@ -17,10 +17,10 @@ Event parameter names are part of the public contract a subscriber codes against
Use full, unabbreviated names: `(SalesHeader: Record "Sales Header"; DocumentNo: Code[20]; Amount: Decimal)`. Record parameters mirror the table name without spaces, and value parameters read as whole words so the contract is unambiguous.
See sample: `name-event-parameters-without-abbreviations.good.al`.
See sample: [`name-event-parameters-without-abbreviations.good.al`](name-event-parameters-without-abbreviations.good.al).
## Anti Pattern
Abbreviated parameter names (`SalesHdr`, `DocNo`, `Amt`) that obscure meaning and vary across publishers, so subscribers must guess what each one holds. Detection: event parameters whose names are truncated forms of the table name or contracted words rather than the full term.
See sample: `name-event-parameters-without-abbreviations.bad.al`.
See sample: [`name-event-parameters-without-abbreviations.bad.al`](name-event-parameters-without-abbreviations.bad.al).

View file

@ -17,10 +17,10 @@ An event name should tell a subscriber where in the publisher the event fires. T
Name by position: `OnBeforePostSalesLine` and `OnAfterPostSalesLine` at the routine boundaries, and `OnPostSalesLineOnAfterCalcAmounts` for an event raised partway through `PostSalesLine` after an amount calculation. The name alone then tells a subscriber both the host routine and the exact point it runs.
See sample: `name-events-by-publisher-position.good.al`.
See sample: [`name-events-by-publisher-position.good.al`](name-events-by-publisher-position.good.al).
## Anti Pattern
Ad-hoc event names that omit the host routine or the before/after position (`MyCustomSalesEvent`, `BeforePost`, `SalesLineEvent`), leaving subscribers unable to tell when the event fires relative to the publisher's logic. Detection: publisher names that do not follow the `OnBefore`/`OnAfter<Routine>` or `On<Routine>OnBefore`/`OnAfter<Context>` patterns.
See sample: `name-events-by-publisher-position.bad.al`.
See sample: [`name-events-by-publisher-position.bad.al`](name-events-by-publisher-position.bad.al).

View file

@ -17,10 +17,10 @@ Before adding a publisher, check whether an event already fires at that point in
When the data you need is already exposed at an existing event, subscribe to it. When the event lacks a parameter, extend that event by appending the parameter at the end — one publisher, one raise — rather than adding a second event beside it.
See sample: `prefer-reusing-or-extending-existing-events.good.al`.
See sample: [`prefer-reusing-or-extending-existing-events.good.al`](prefer-reusing-or-extending-existing-events.good.al).
## Anti Pattern
Adding a second event raise immediately after an existing one, or creating `OnBeforeProcessOrderWithCustomer` next to `OnBeforeProcessOrder` just to add a single parameter. Detection: two consecutive `OnBefore…`/`OnAfter…` raises with no logic between them, or near-duplicate event names differing only by a parameter-describing suffix.
See sample: `prefer-reusing-or-extending-existing-events.bad.al`.
See sample: [`prefer-reusing-or-extending-existing-events.bad.al`](prefer-reusing-or-extending-existing-events.bad.al).

View file

@ -17,10 +17,10 @@ When designing a new publisher, setting `IncludeSender` to `true` on `[Integrati
For a new event, declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "…"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly.
See sample: `prefer-this-over-includesender-in-codeunit-events.good.al`.
See sample: [`prefer-this-over-includesender-in-codeunit-events.good.al`](prefer-this-over-includesender-in-codeunit-events.good.al).
## Anti Pattern
Designing a new codeunit event with `[IntegrationEvent(true, …)]` solely to hand subscribers the publisher instance, where `this` could be passed explicitly as a typed parameter. Do not apply this rule by mutating a shipped event's attribute flags.
See sample: `prefer-this-over-includesender-in-codeunit-events.bad.al`.
See sample: [`prefer-this-over-includesender-in-codeunit-events.bad.al`](prefer-this-over-includesender-in-codeunit-events.bad.al).

View file

@ -17,10 +17,10 @@ When a record passed to an event is a temporary record — an in-memory buffer n
Name temporary record parameters with a `Temp` prefix, for example `var TempSalesLineBuffer: Record "Sales Line" temporary`, so every subscriber sees immediately that the record is an in-memory buffer and treats writes accordingly.
See sample: `prefix-temporary-record-event-parameters-with-temp.good.al`.
See sample: [`prefix-temporary-record-event-parameters-with-temp.good.al`](prefix-temporary-record-event-parameters-with-temp.good.al).
## Anti Pattern
A temporary record parameter named without the `Temp` prefix (`var SalesLineBuffer: Record "Sales Line" temporary`), so subscribers cannot tell the record is non-persistent and may rely on writes that are silently discarded. Detection: an event parameter declared `temporary` whose name does not start with `Temp`.
See sample: `prefix-temporary-record-event-parameters-with-temp.bad.al`.
See sample: [`prefix-temporary-record-event-parameters-with-temp.bad.al`](prefix-temporary-record-event-parameters-with-temp.bad.al).

View file

@ -17,10 +17,10 @@ A routine that exposes both an `OnBefore…` event (with `var IsHandled`) and a
Wrap only the default work in `if not IsHandled then begin … end;` and keep the `OnAfterX(…)` raise after that block, outside the guard, so it always fires regardless of whether a subscriber handled the OnBefore. This keeps the override seam and the after-notification independent, which is what subscribers expect.
See sample: `preserve-onafter-execution-when-ishandled-skips-the-body.good.al`.
See sample: [`preserve-onafter-execution-when-ishandled-skips-the-body.good.al`](preserve-onafter-execution-when-ishandled-skips-the-body.good.al).
## Anti Pattern
Guarding with `if IsHandled then exit;` and placing the `OnAfterX` raise later in the same routine, so handling the OnBefore short-circuits the whole procedure and the OnAfter event is skipped along with the body. Detection: an `if IsHandled then exit;` in a routine that also raises a paired `OnAfter…` event after that point.
See sample: `preserve-onafter-execution-when-ishandled-skips-the-body.bad.al`.
See sample: [`preserve-onafter-execution-when-ishandled-skips-the-body.bad.al`](preserve-onafter-execution-when-ishandled-skips-the-body.bad.al).

View file

@ -17,10 +17,10 @@ A key operation — a posting, release, or validation routine — becomes a hard
Wrap the operation's core with events: raise `OnBeforeX(var Rec, var IsHandled)` before the default work and `OnAfterX(var Rec)` once it succeeds, at the natural boundaries of the routine. Declare each publisher `[IntegrationEvent(false, false)] local procedure` with an empty body and let the calling routine — never the publisher — own the logic. Pass records by `var` so subscribers can read and adjust them, and include the parameters a subscriber would need to act. This gives partners a stable seam without touching base code.
See sample: `publish-thin-onbefore-onafter-integration-events.good.al`.
See sample: [`publish-thin-onbefore-onafter-integration-events.good.al`](publish-thin-onbefore-onafter-integration-events.good.al).
## Anti Pattern
Business logic placed inside an `[IntegrationEvent]` publisher method, so the "event" actually mutates state every time it is raised — defeating the hook and surprising every reader — or a core operation that exposes no extension points at all, forcing partners to overwrite or duplicate it. Detection: an `[IntegrationEvent]`/`[BusinessEvent]` method whose body contains statements rather than being empty, or a posting/validation routine with no surrounding `OnBefore`/`OnAfter` publishers.
See sample: `publish-thin-onbefore-onafter-integration-events.bad.al`.
See sample: [`publish-thin-onbefore-onafter-integration-events.bad.al`](publish-thin-onbefore-onafter-integration-events.bad.al).

View file

@ -17,10 +17,10 @@ A routine that raises an `OnBefore…` integration event with a `var IsHandled:
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`.
See sample: [`reset-ishandled-only-when-the-value-can-carry-over.good.al`](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`.
See sample: [`reset-ishandled-only-when-the-value-can-carry-over.bad.al`](reset-ishandled-only-when-the-value-can-carry-over.bad.al).

View file

@ -17,10 +17,10 @@ The `local` and `internal` access modifiers on Business and Integration event pu
Preserve a shipped Business or Integration event's identity and every existing parameter's name, type/subtype, and passing mode regardless of the procedure access modifier. AS0025 protects names and types, while AS0063 and AS0077 protect removal and addition of `var`. New parameters may be added at any position on a `local` or `internal` event because subscribers can omit them; public event procedures follow the stricter caller contract described by `add-new-event-parameters-at-the-end`.
See sample: `treat-local-and-internal-events-as-subscriber-contracts.good.al`.
See sample: [`treat-local-and-internal-events-as-subscriber-contracts.good.al`](treat-local-and-internal-events-as-subscriber-contracts.good.al).
## Anti Pattern
Renaming or removing an existing parameter, changing its type/subtype, or adding/removing its `var` modifier because the event publisher procedure is `local` or `internal`. AppSourceCop checks these subscriber-breaking changes because dependent event subscribers can still bind to the event. Reordering unchanged parameters, or inserting a new parameter among them, is not this anti-pattern.
See sample: `treat-local-and-internal-events-as-subscriber-contracts.bad.al`.
See sample: [`treat-local-and-internal-events-as-subscriber-contracts.bad.al`](treat-local-and-internal-events-as-subscriber-contracts.bad.al).

View file

@ -17,10 +17,10 @@ AL has no method overriding, so a `procedure` that runs its body unconditionally
Raise `OnBeforeX(…, IsHandled)` as the first step of the routine and guard with `if IsHandled then exit;` before any default logic runs. Declare the publisher `[IntegrationEvent(false, false)] local procedure OnBeforeX(…; var IsHandled: Boolean)` with an empty body, and keep `IsHandled` a `var` parameter so a subscriber can write to it. A subscriber that replaces the behaviour does its work and sets `IsHandled := true`; one that only augments leaves it untouched and guards with `if IsHandled then exit;` itself. Reserve the override hook for cases where a partner genuinely needs to replace logic — when the goal is only to react, a positive `OnAfter` event is the better seam.
See sample: `use-ishandled-to-make-base-behaviour-overridable.good.al`.
See sample: [`use-ishandled-to-make-base-behaviour-overridable.good.al`](use-ishandled-to-make-base-behaviour-overridable.good.al).
## Anti Pattern
Two shapes. First, a routine whose default logic always runs because there is no `OnBefore…`/`IsHandled` hook at all — extensions cannot change it without overwriting base code. Second, a routine that raises `OnBeforeX(IsHandled)` but omits the `if IsHandled then exit;` guard, so the default logic still executes after a subscriber set `IsHandled := true`, duplicating work and side effects. Detection: an `OnBefore` publisher with a `var IsHandled: Boolean` parameter whose caller never tests `IsHandled`, or a public routine doing non-trivial work with no overridable seam.
See sample: `use-ishandled-to-make-base-behaviour-overridable.bad.al`.
See sample: [`use-ishandled-to-make-base-behaviour-overridable.bad.al`](use-ishandled-to-make-base-behaviour-overridable.bad.al).

View file

@ -17,10 +17,10 @@ An interface variable can hold any codeunit that `implements` the interface, ass
Declare the dependency as an `Interface` variable on the consumer and supply the implementation from outside — typically setter injection through a procedure that takes an `Interface` parameter, or a parameter on the entry method. Production passes the real implementation codeunit; a test passes a test-double codeunit that implements the same interface with deterministic behaviour. Because a codeunit assigns to an interface variable directly, no enum or factory is needed for the injectable case. The consumer's logic is then verifiable in isolation.
See sample: `assign-codeunit-to-interface-for-testability.good.al`.
See sample: [`assign-codeunit-to-interface-for-testability.good.al`](assign-codeunit-to-interface-for-testability.good.al).
## Anti Pattern
A consumer that declares its dependency as a concrete `Codeunit "..."` variable and calls it directly. The collaborator cannot be substituted, so a unit test either runs the production side effects or cannot cover the consumer at all. Detection signal: a `var` of type `Codeunit "<concrete impl>"` used for a collaborator that has — or could have — an interface, especially one that performs I/O, posting, or external calls. Extract an interface, depend on the interface variable, and inject the implementation.
See sample: `assign-codeunit-to-interface-for-testability.bad.al`.
See sample: [`assign-codeunit-to-interface-for-testability.bad.al`](assign-codeunit-to-interface-for-testability.bad.al).

View file

@ -17,10 +17,10 @@ Adding a method to a shipped interface changes the contract every implementing c
On BC25 or later, declare a new interface that `extends` the published interface and add the new method there. Existing implementers remain valid for the original contract, while new implementers opt in to the extended contract. For targets BC16 through BC24, where interface inheritance is unavailable, publish a new or versioned sibling interface instead.
See sample: `extend-published-interfaces-dont-edit-them.good.al`.
See sample: [`extend-published-interfaces-dont-edit-them.good.al`](extend-published-interfaces-dont-edit-them.good.al).
## Anti Pattern
Adding a procedure directly to an interface that has already shipped. Every dependent implementation must immediately add that procedure, so an otherwise compatible app update breaks its implementers.
See sample: `extend-published-interfaces-dont-edit-them.bad.al`.
See sample: [`extend-published-interfaces-dont-edit-them.bad.al`](extend-published-interfaces-dont-edit-them.bad.al).

View file

@ -17,10 +17,10 @@ An enum ordinal can remain in persisted data after the enum extension that decla
On BC18 or later, set `UnknownValueImplementation = <Interface> = <Codeunit>;` on an enum that implements an interface and can be persisted. Use an implementation that reports a clear domain error or safely contains the unknown state. Keep `DefaultImplementation` separately when declared but unmapped values also need a fallback.
See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al`.
See sample: [`handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al`](handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al).
## Anti Pattern
Defining only `DefaultImplementation` and assuming it also handles a stored ordinal whose enum value has disappeared. After an enum extension is uninstalled, converting that unknown ordinal to the interface can produce a technical runtime error instead of controlled handling.
See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al`.
See sample: [`handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al`](handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al).

View file

@ -17,10 +17,10 @@ When behaviour varies by a discrete "type" — a shipping method, a posting stra
Declare an `interface` with the method signatures only (no bodies). Define an `enum` that `implements` the interface and set `Implementation = <Interface> = <Codeunit>;` on each value, pointing at a codeunit that `implements` the same interface. In the consumer, declare a variable of the interface type, assign the enum value to it, and call the method — the platform dispatches to the codeunit mapped to that value. New variants plug in by adding an enum value and its implementation; existing call sites are untouched. The open/closed boundary lives at the enum, not scattered across `case` blocks.
See sample: `prefer-interface-over-case-branching.good.al`.
See sample: [`prefer-interface-over-case-branching.good.al`](prefer-interface-over-case-branching.good.al).
## Anti Pattern
A `case "Shipping Method" of` block that selects behaviour inline, duplicated across the call sites that need it. Each new method forces a synchronized edit to every block, and a missed branch is a silent gap. Detection signal: a `case` statement over an enum value whose branches choose between variant computations or strategies, especially when the same shape appears in more than one procedure. Replace the enum with one that `implements` an interface, move each branch body into an implementation codeunit, and let dispatch happen through an interface variable.
See sample: `prefer-interface-over-case-branching.bad.al`.
See sample: [`prefer-interface-over-case-branching.bad.al`](prefer-interface-over-case-branching.bad.al).

View file

@ -17,10 +17,10 @@ An `enum` that `implements` an interface maps each declared value to a codeunit
On any extensible enum that implements an interface, set `DefaultImplementation = <Interface> = <Codeunit>;` at the enum level, pointing at a safe implementation. Values with their own `Implementation` keep using it; declared values without one resolve to the default. Do not rely on this property for persisted ordinals that match no declared enum value.
See sample: `set-defaultimplementation-on-enum.good.al`.
See sample: [`set-defaultimplementation-on-enum.good.al`](set-defaultimplementation-on-enum.good.al).
## Anti Pattern
An extensible `enum ... implements <Interface>` where at least one value sets no `Implementation` and the enum declares no `DefaultImplementation`. Code that assigns that value to an interface variable and invokes a method throws at the call site, and because the enum is extensible the failing value can be introduced by a third party long after the consumer ships. Detection signal: an enum that implements an interface, has a `value(...)` with no `Implementation`, and no enum-level `DefaultImplementation`. Add a `DefaultImplementation` mapping to close the gap.
See sample: `set-defaultimplementation-on-enum.bad.al`.
See sample: [`set-defaultimplementation-on-enum.bad.al`](set-defaultimplementation-on-enum.bad.al).

View file

@ -17,10 +17,10 @@ Report dataitem field selection is calculated at compile time and once per datai
When a dataitem trigger needs an extra field, add that field in `OnPreDataItem` before iteration starts. This supplements the compiler-selected fields and avoids the first just-in-time load and enumerator update when the trigger reads the extra field.
See sample: `addloadfields-in-report-onpredataitem.good.al`.
See sample: [`addloadfields-in-report-onpredataitem.good.al`](addloadfields-in-report-onpredataitem.good.al).
## Anti Pattern
Listing every dataset column in `AddLoadFields`, or omitting a known trigger-only field because the dataset already uses other fields. The former is redundant; the latter causes a just-in-time load on first access and can cause repeated loads when the record is copied or passed by value.
See sample: `addloadfields-in-report-onpredataitem.bad.al`.
See sample: [`addloadfields-in-report-onpredataitem.bad.al`](addloadfields-in-report-onpredataitem.bad.al).

View file

@ -17,10 +17,10 @@ A `SetRange` or `SetFilter` placed before `FindSet` narrows the result set at th
Move every predicate that can be expressed as an equality or range filter into a `SetRange` or `SetFilter` ahead of the find. Make sure a key (index) exists whose leading fields cover the filter so the optimizer can seek; note that `SetCurrentKey` only sets sort order and is not an index hint (see `setcurrentkey-sets-sort-order-not-index-hint.md`). The loop body should then contain only the work that depends on per-row state.
See sample: `apply-filters-before-iterating.good.al`.
See sample: [`apply-filters-before-iterating.good.al`](apply-filters-before-iterating.good.al).
## Anti Pattern
`if Customer.FindSet() then repeat if Customer."Country/Region Code" = 'US' then ProcessCustomer(Customer); until Customer.Next() = 0;` — the loop pays for every row in the table and discards the non-matching ones in AL. The intent is the same as a `SetRange("Country/Region Code", 'US')` ahead of the find, but the cost is not.
See sample: `apply-filters-before-iterating.bad.al`.
See sample: [`apply-filters-before-iterating.bad.al`](apply-filters-before-iterating.bad.al).

View file

@ -17,10 +17,10 @@ A `Get` (or any other database call) executed before a guard that may exit the p
Read the procedure top-to-bottom and place every condition that can short-circuit ahead of every database call. The check `if SomeNo = '' then exit;` belongs above `Header.Get(...)`, not below. Each guard moved upward saves one wasted query on the path that exits.
See sample: `apply-guards-before-get.good.al`.
See sample: [`apply-guards-before-get.good.al`](apply-guards-before-get.good.al).
## Anti Pattern
`Record.Get(...)` at the top of a procedure followed by `if SomeField = '' then exit;`. The code reads top-down as "load the record, then decide whether we needed it" — exactly the order that wastes the query. The pattern is easy to introduce when guards are added later, defensively, without re-checking call ordering.
See sample: `apply-guards-before-get.bad.al`.
See sample: [`apply-guards-before-get.bad.al`](apply-guards-before-get.bad.al).

View file

@ -17,10 +17,10 @@ Microsoft's [AL database-method performance guidance](https://learn.microsoft.co
Use `FindSet(true)` when the loop writes the traversed rows, and call `Modify` or `Delete` on that iterating record variable. If generic code is required, open and iterate the `RecordRef` directly instead of calling `GetTable` for each typed record. Keep a per-row loop when validation or row-specific behavior is required; this rule does not imply that `ModifyAll` or `DeleteAll` is equivalent.
See sample: `avoid-cloning-records-before-modify-delete-in-loops.good.al`.
See sample: [`avoid-cloning-records-before-modify-delete-in-loops.good.al`](avoid-cloning-records-before-modify-delete-in-loops.good.al).
## Anti Pattern
Inside an active traversal, copy the current row, convert it with `RecordRef.GetTable`, or pass it without `var` to a helper, then call `Modify` or `Delete` on that clone. Do not flag read-only snapshots, temporary records, or copies used to write a different target table; the documented extra-statement concern is clone-before-write on the traversed table.
See sample: `avoid-cloning-records-before-modify-delete-in-loops.bad.al`.
See sample: [`avoid-cloning-records-before-modify-delete-in-loops.bad.al`](avoid-cloning-records-before-modify-delete-in-loops.bad.al).

View file

@ -21,10 +21,10 @@ A durability checkpoint inside an outer batch loop can be valid only when the sa
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`.
See sample: [`avoid-commit-inside-loops.good.al`](avoid-commit-inside-loops.good.al).
## Anti Pattern
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`.
See sample: [`avoid-commit-inside-loops.bad.al`](avoid-commit-inside-loops.bad.al).

View file

@ -19,10 +19,10 @@ application-area: [all]
Put display-only results in page variables assigned in `OnAfterGetRecord` without calling `Update`. If the page must refresh after an action, call `CurrPage.Update(false)` from `OnAction` once, not per row.
See sample: `avoid-currpage-update-in-onaftergetrecord.good.al`.
See sample: [`avoid-currpage-update-in-onaftergetrecord.good.al`](avoid-currpage-update-in-onaftergetrecord.good.al).
## Anti Pattern
`trigger OnAfterGetRecord() begin ... CurrPage.Update(); end;` on a list. The signal is `CurrPage.Update` inside `OnAfterGetRecord` or `OnAfterGetCurrRecord` without an explicit user action.
See sample: `avoid-currpage-update-in-onaftergetrecord.bad.al`.
See sample: [`avoid-currpage-update-in-onaftergetrecord.bad.al`](avoid-currpage-update-in-onaftergetrecord.bad.al).

View file

@ -17,10 +17,10 @@ A `Get` or `FindFirst` against another persistent table inside a loop can produc
Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If keys repeat, a dictionary cache can reduce lookups to one per distinct key. `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so.
See sample: `avoid-get-inside-loop-on-large-table.good.al`.
See sample: [`avoid-get-inside-loop-on-large-table.good.al`](avoid-get-inside-loop-on-large-table.good.al).
## Anti Pattern
Iterating production BOM lines and calling `Item.Get(BOMLine."No.")` for each line when the same result can be produced by a query joining Production BOM Line to Item. Partial loading alone is only a payload mitigation for this pattern.
See sample: `avoid-get-inside-loop-on-large-table.bad.al`.
See sample: [`avoid-get-inside-loop-on-large-table.bad.al`](avoid-get-inside-loop-on-large-table.bad.al).

View file

@ -19,10 +19,10 @@ A codeunit with `SingleInstance = true` is allocated once per session and lives
Keep the global footprint on a SingleInstance subscriber bounded and intentional: a handful of flags, a setup record, a bounded cache with a maximum size. When cross-event state is genuinely needed, define an explicit reset point — end of a business process, arrival of a specific terminal event — that clears the growing collection.
See sample: `avoid-growing-globals-in-singleinstance-subscribers.good.al`.
See sample: [`avoid-growing-globals-in-singleinstance-subscribers.good.al`](avoid-growing-globals-in-singleinstance-subscribers.good.al).
## Anti Pattern
A SingleInstance subscriber that appends each event's payload to a global list, dictionary, or temporary record without a cap or cleanup trigger. The list grows for hours, memory pressure builds quietly, and debugging the root cause on a live environment is substantially harder than noticing the unbounded append in code review.
See sample: `avoid-growing-globals-in-singleinstance-subscribers.bad.al`.
See sample: [`avoid-growing-globals-in-singleinstance-subscribers.bad.al`](avoid-growing-globals-in-singleinstance-subscribers.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
Use `RecordRef`/`FieldRef` for genuinely generic code — permission checks, field copying, table-agnostic export. When the loop target is known at compile time and the loop iterates a large number of rows, declare the typed record and access fields directly; the saved per-iteration overhead is measurable at the volumes the rule targets.
See sample: `avoid-recordref-in-hot-loop.good.al`.
See sample: [`avoid-recordref-in-hot-loop.good.al`](avoid-recordref-in-hot-loop.good.al).
## Anti Pattern
`RecRef.Open(Database::Customer); if RecRef.FindSet() then repeat FldRef := RecRef.Field(Customer.FieldNo(Name)); ProcessName(FldRef.Value); until RecRef.Next() = 0;` — the table is fixed at compile time, the field is fixed at compile time, and the loop pays the dynamic-resolution cost on every iteration. The direct `Customer.Name` form does the same work without the lookup.
See sample: `avoid-recordref-in-hot-loop.bad.al`.
See sample: [`avoid-recordref-in-hot-loop.bad.al`](avoid-recordref-in-hot-loop.bad.al).

View file

@ -17,10 +17,10 @@ A list or card page's `OnAfterGetRecord` trigger fires *because* the platform ha
Inside page triggers — `OnAfterGetRecord`, `OnAfterGetCurrRecord`, validation triggers — read from `Rec` (or the trigger's record parameter). The platform exposes the freshly loaded record there for exactly this purpose. Reach for `Get` only when the trigger needs a *different* record than the one being displayed.
See sample: `avoid-redundant-get-when-record-already-loaded.good.al`.
See sample: [`avoid-redundant-get-when-record-already-loaded.good.al`](avoid-redundant-get-when-record-already-loaded.good.al).
## Anti Pattern
`AssemblyLineRec.Get("Document Type", "Document No.", "Line No.");` at the top of `OnAfterGetRecord`, when the trigger is on the `Assembly Line` page itself and `Rec` already holds that row. The pattern often appears when a helper that expects a record parameter is invoked from a page trigger and the author writes a `Get` to "freshen" `Rec` rather than passing `Rec` through.
See sample: `avoid-redundant-get-when-record-already-loaded.bad.al`.
See sample: [`avoid-redundant-get-when-record-already-loaded.bad.al`](avoid-redundant-get-when-record-already-loaded.bad.al).

View file

@ -17,10 +17,10 @@ A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a wr
Sequence the operation so user confirmation happens *before* any database write that takes a lock the prompt holds open. The shape is: ask the user → if confirmed, acquire locks and post. `if Confirm(...) then begin SalesHeader.LockTable(); SalesHeader.Get(DocNo); PostSalesOrder(SalesHeader); end;` keeps the lock window down to the work itself.
See sample: `avoid-user-prompts-inside-transactions.good.al`.
See sample: [`avoid-user-prompts-inside-transactions.good.al`](avoid-user-prompts-inside-transactions.good.al).
## Anti Pattern
`SalesHeader.LockTable(); SalesHeader.Get(DocNo); if Confirm('Post this order?') then ...;` — the lock is held for as long as the dialog is up. A user who steps away to lunch holds the lock for an hour, and every other session that touches that row blocks for the duration.
See sample: `avoid-user-prompts-inside-transactions.bad.al`.
See sample: [`avoid-user-prompts-inside-transactions.bad.al`](avoid-user-prompts-inside-transactions.bad.al).

View file

@ -19,10 +19,10 @@ application-area: [all]
Inside a multi-row insert, call `"No. Series - Batch".GetNextNo` per row and `SaveState` once after the loop when the series must remain gapless. Use `NumberSequence.Next` when holes are allowed. Do not replace a single `OnInsert` `GetNextNo` for one master record; that path is not the hotspot.
See sample: `batch-number-series-instead-of-getnextno-per-row.good.al`.
See sample: [`batch-number-series-instead-of-getnextno-per-row.good.al`](batch-number-series-instead-of-getnextno-per-row.good.al).
## Anti Pattern
`NoSeries.GetNextNo(...)` inside `repeat ... Insert ... until Next() = 0` where the series is **gapless** (Allow Gaps = false). Each iteration takes the series-line lock. The signal is `"No. Series"` (not `"No. Series - Batch"`) in a loop that inserts more than one row; do not flag the same pattern when the series has Allow Gaps enabled, as the `NumberSequence` path already avoids the lock.
See sample: `batch-number-series-instead-of-getnextno-per-row.bad.al`.
See sample: [`batch-number-series-instead-of-getnextno-per-row.bad.al`](batch-number-series-instead-of-getnextno-per-row.bad.al).

View file

@ -23,13 +23,13 @@ For an `or`-shaped condition, do not nest: nesting `if A then if B then Action`
Where a chain of `and`-guards runs past about three conditions, stop nesting and use a `case` statement instead — see `case-true-of-for-long-condition-chains.md`. Keep `and` and `or` for operands that are independently safe and cheap — in-memory field comparisons, enum tests, bound checks — where combining them reads better and costs nothing.
See sample: `boolean-operators-do-not-short-circuit.good.al`.
See sample: [`boolean-operators-do-not-short-circuit.good.al`](boolean-operators-do-not-short-circuit.good.al).
## Anti Pattern
A single condition that joins a guard with an operand depending on that guard, or with an expensive operand, using `and` or `or`. The consequence is either wasted work on every evaluation — a database call or validation procedure invoked even when the outcome is already decided — or a runtime error or silently wrong result that the guard was written to prevent. Applying the `and` fix to an `or` condition is a distinct mistake: rewriting `A or B` as nested `if`s drops the `A`-true/`B`-false case instead of preserving it. Detection signals: an operand that indexes an array or list with a variable whose bounds are checked in a sibling operand; `Record.Get(...)` or a `Find`/`IsEmpty` call as one operand of `and` with a field read of the same record as another; an expensive or unsafe operand combined with `or` next to a condition that alone already makes the result true; a boolean-returning procedure call combined with a cheap field test. The pattern is common in code ported from a language that does short-circuit, and in conditions grown by appending a clause to an existing `if`.
See sample: `boolean-operators-do-not-short-circuit.bad.al`.
See sample: [`boolean-operators-do-not-short-circuit.bad.al`](boolean-operators-do-not-short-circuit.bad.al).
## See also

View file

@ -17,10 +17,10 @@ application-area: [all]
When the procedure totals a FlowField (or several) across a filtered set, set the filters, then call `CalcSums("Field 1", "Field 2", ...)`. The platform issues one query; the result is read off the record's FlowField slot. Single `CalcFields` outside loops is fine, and `CalcFields` on the current row in a page's `OnAfterGetRecord` or in `OnValidate` is the standard pattern — those are per-action, not per-row over a large set.
See sample: `calcsums-instead-of-calcfields-in-loop.good.al`.
See sample: [`calcsums-instead-of-calcfields-in-loop.good.al`](calcsums-instead-of-calcfields-in-loop.good.al).
## Anti Pattern
`if CustLedgerEntry.FindSet() then repeat CustLedgerEntry.CalcFields("Remaining Amount"); Total += CustLedgerEntry."Remaining Amount"; until CustLedgerEntry.Next() = 0;` — exactly the upstream-flagged shape. The iteration is the cheap part; the per-row `CalcFields` is what scales linearly with table size.
See sample: `calcsums-instead-of-calcfields-in-loop.bad.al`.
See sample: [`calcsums-instead-of-calcfields-in-loop.bad.al`](calcsums-instead-of-calcfields-in-loop.bad.al).

View file

@ -17,13 +17,13 @@ Because AL gives no short-circuit guarantee for `and` and `or`, a chain of condi
Sequence two or three dependent conditions with nested `if`. Beyond that, switch to `case`: use `case false of` for a chain of guards where every condition must hold, letting control fall past `end` when all of them pass; use `case true of` for first-match dispatch, where each later probe runs only if the earlier ones did not match. Comma-separate conditions into one value set only when every one of them is a pure, order-independent test with no side effect — a field comparison, an enum check, a bound test — so it makes no difference whether AL evaluates all of them or stops early; grouping these costs nothing and removes the repeated action. A condition that guards another, or that carries a side effect or a cost of its own — a `Get`, a `Find`, a procedure call — keeps its own value set, placed immediately after the value set it depends on, so the code relies only on the ordering the documentation actually states. A value set needs no parentheses around a comparison, unlike an operand of `and` or `or`: the AL operator hierarchy places `and` and `or` above the comparison operators, so parentheses are mandatory there and the chain fills up with them. This keeps every condition at one indentation level, makes evaluation order explicit rather than implied by nesting, and preserves the stop-at-first-match behaviour it relies on. It also aligns with the AL programming convention that more than two alternatives belong in a `case` statement rather than an `if-then-else`.
See sample: `case-true-of-for-long-condition-chains.good.al`.
See sample: [`case-true-of-for-long-condition-chains.good.al`](case-true-of-for-long-condition-chains.good.al).
## Anti Pattern
An `if` ladder four or more levels deep whose only purpose is sequencing guards. Detection: a chain of nested `if` statements with no `else`, each condition guarding the one below it, terminating in a single action or `exit`; or the same `exit`/`error` duplicated at every level of such a nested chain, purely to escape it. The second, worse form is collapsing that ladder into one `and` chain to escape the nesting — that trades indentation for a real defect, because the operands are still all evaluated. A third, subtler form is over-applying the comma-grouping itself: putting a guard and the condition it protects — for example `Item.Get(...)` and a read of a field on that same record — into one comma-separated value set. That relies on an evaluation order within a single value set that the documentation does not state; keep them in separate value sets instead. Reach for `case` over nested `if` or a collapsed `and` chain, and keep order-dependent conditions in their own value sets within it.
See sample: `case-true-of-for-long-condition-chains.bad.al`.
See sample: [`case-true-of-for-long-condition-chains.bad.al`](case-true-of-for-long-condition-chains.bad.al).
## See also

View file

@ -19,10 +19,10 @@ application-area: [all]
Group work by company. Call `ChangeCompany` once per distinct company, then `FindSet`/`Get` that company's rows. If the record variable is reused afterward, call `ChangeCompany()` without a company name to redirect it back to the current company.
See sample: `changecompany-in-loop-drops-caches.good.al`.
See sample: [`changecompany-in-loop-drops-caches.good.al`](changecompany-in-loop-drops-caches.good.al).
## Anti Pattern
`repeat Rec.ChangeCompany(Buffer.Company); Rec.Get(Buffer."No."); until Buffer.Next() = 0` when `Buffer` is not ordered by company, or even when it is — if `ChangeCompany` still runs every row. The signal is `ChangeCompany` inside `repeat`/`while` keyed by a document line rather than by a company loop.
See sample: `changecompany-in-loop-drops-caches.bad.al`.
See sample: [`changecompany-in-loop-drops-caches.bad.al`](changecompany-in-loop-drops-caches.bad.al).

View file

@ -19,7 +19,7 @@ application-area: [all]
Measure aggregate-read latency and write cost under realistic filters and volumes. Keep `MaintainSIFTIndex = true` when the maintained aggregate materially benefits frequent `CalcSums` or FlowField reads. Consider `false` when writes dominate and the less-frequent aggregate reads can tolerate calculation from the base table.
See sample: `choose-maintainsiftindex-by-read-write-ratio.good.al`.
See sample: [`choose-maintainsiftindex-by-read-write-ratio.good.al`](choose-maintainsiftindex-by-read-write-ratio.good.al).
## Anti Pattern

View file

@ -17,10 +17,10 @@ application-area: [all]
When a piece of work must either complete fully or have no effect, put it in its own codeunit and invoke it via `Codeunit.Run`, capturing the return. Use `if not Codeunit.Run(X) then Error(...)` to abort and unwind; use the plain boolean branch to react to failure without aborting the caller. This replaces the SQL-style `BEGIN TRAN / COMMIT / ROLLBACK` habit with a pattern the AL runtime implements natively. Do not confuse `Codeunit.Run` with `[TryFunction]` — both catch errors, but only `Codeunit.Run` rolls back database changes on failure (see `use-tryfunction-for-error-catching-not-rollback.md`). Note that if the caller is already in a write transaction, the platform requires a `Commit()` before `Codeunit.Run` — the sub-operation cannot nest inside an open transaction (see `codeunit-run-requires-prior-commit-inside-transaction.md`).
See sample: `codeunit-run-as-atomic-sub-operation.good.al`.
See sample: [`codeunit-run-as-atomic-sub-operation.good.al`](codeunit-run-as-atomic-sub-operation.good.al).
## Anti Pattern
Inlining the work in the caller and sprinkling `Commit()` to simulate sub-transaction boundaries. The caller's enclosing transaction is fused to the sub-work; any Commit between checkpoints survives subsequent errors, and any errors after a Commit cannot be cleanly unwound. Per-row Commits (see `avoid-commit-inside-loops.md`) are a frequent symptom.
See sample: `codeunit-run-as-atomic-sub-operation.bad.al`.
See sample: [`codeunit-run-as-atomic-sub-operation.bad.al`](codeunit-run-as-atomic-sub-operation.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
For the `Codeunit.Run` atomic-sub-operation pattern (see `codeunit-run-as-atomic-sub-operation.md`) to work in a loop, keep the outer scope **read-only**. Move per-iteration writes — progress updates, logging, audit entries — into the sub-codeunit so they commit or roll back together with the per-item work. If logging must live outside the atomic boundary, defer it: collect failure info in memory during the loop (a `List of [Text]`, a temporary record, local variables) and write it in one pass after the loop ends, when no outer write transaction is open.
See sample: `codeunit-run-requires-prior-commit-inside-transaction.good.al`.
See sample: [`codeunit-run-requires-prior-commit-inside-transaction.good.al`](codeunit-run-requires-prior-commit-inside-transaction.good.al).
## Anti Pattern
Inserting `Commit()` before each `Codeunit.Run` to silence the runtime error. The error goes away, but the outer scope now commits per iteration — the behavior `avoid-commit-inside-loops.md` exists to warn against. Attempting to silence the implicit commit inside the sub-codeunit with `[CommitBehavior(CommitBehavior::Ignore)]` also fails: the attribute does not apply to `Codeunit.Run`'s implicit commit. Conditioning the Commit on `Database.IsInWriteTransaction()` (runtime 11.0+) is another version of the same trap — the method has legitimate uses for diagnostics and library code that genuinely cannot control its caller, but branching production flow on runtime transaction state typically signals unclear ownership that would be better fixed by restructuring the caller so transaction state is predictable.
See sample: `codeunit-run-requires-prior-commit-inside-transaction.bad.al`.
See sample: [`codeunit-run-requires-prior-commit-inside-transaction.bad.al`](codeunit-run-requires-prior-commit-inside-transaction.bad.al).

View file

@ -19,10 +19,10 @@ application-area: [all]
On report objects and `PageType = API` pages with `Editable = false` that never write, set `DataAccessIntent = ReadOnly`. For query objects, set it when the query is consumed via OData or an API endpoint. Keep the default on objects that insert, modify, or call a write codeunit from a processing-only report.
See sample: `dataaccessintent-readonly-on-analytical-objects.good.al`.
See sample: [`dataaccessintent-readonly-on-analytical-objects.good.al`](dataaccessintent-readonly-on-analytical-objects.good.al).
## Anti Pattern
A listing report or API query with no `DataAccessIntent` that scans G/L or sales lines. The object is read-only in practice and still loads the primary.
See sample: `dataaccessintent-readonly-on-analytical-objects.bad.al`.
See sample: [`dataaccessintent-readonly-on-analytical-objects.bad.al`](dataaccessintent-readonly-on-analytical-objects.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
Reserve `LockTable` for the read directly before a `Modify`, `Insert`, or `Delete` that depends on the read value. If a helper is sometimes called for reading and sometimes for writing, split it into separate read and write paths and call `LockTable` only on the write path. For read-only existence checks or lookups, the right primitive is `ReadIsolation` (see `prefer-readisolation-over-locktable-for-reads.md`).
See sample: `do-not-locktable-in-read-only-procedure.good.al`.
See sample: [`do-not-locktable-in-read-only-procedure.good.al`](do-not-locktable-in-read-only-procedure.good.al).
## Anti Pattern
A pure getter that opens with `Rec.LockTable();`. Every caller's transaction now acquires `UPDLOCK` on that table for every subsequent read until commit. The contention shows up as blocking on unrelated sessions whose own code path looks innocent — the locker is invisible to the blocked reader.
See sample: `do-not-locktable-in-read-only-procedure.bad.al`.
See sample: [`do-not-locktable-in-read-only-procedure.bad.al`](do-not-locktable-in-read-only-procedure.bad.al).

View file

@ -17,10 +17,10 @@ A list page's `OnAfterGetRecord` fires once per visible row, every time the user
When the trigger needs to compute display-only state per row, write the result into a page variable (a global on the page object) rather than back to the database. Reserve `Modify` for triggers that fire on an explicit user action — `OnAction`, validation triggers, `OnQueryClosePage` — where one action maps to one write.
See sample: `do-not-modify-in-onaftergetrecord.good.al`.
See sample: [`do-not-modify-in-onaftergetrecord.good.al`](do-not-modify-in-onaftergetrecord.good.al).
## Anti Pattern
`trigger OnAfterGetRecord() begin Rec."Warning Flag" := CalcWarning(); Rec.Modify(); end;` — on a list page over a moderately sized table, scrolling through fifty rows produces fifty writes. The page feels slow, the table accumulates churn, and the warning flag — which is recomputed on every refresh anyway — never needed persistence.
See sample: `do-not-modify-in-onaftergetrecord.bad.al`.
See sample: [`do-not-modify-in-onaftergetrecord.bad.al`](do-not-modify-in-onaftergetrecord.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
If a page or record was declared temporary on purpose — to buffer payloads, accept synthetic rows, or expose computed data through an API surface without persisting it — keep it temporary. When removing the property looks necessary, audit the call sites first: a temporary API page is often consumed by integrations that issue many calls per minute, and the round-trip cost is paid per call. If persistence is genuinely required, weigh storage and lock cost against alternatives (a regular table the API page reads from, an event-driven write).
See sample: `do-not-remove-sourcetabletemporary-from-api-page.good.al`.
See sample: [`do-not-remove-sourcetabletemporary-from-api-page.good.al`](do-not-remove-sourcetabletemporary-from-api-page.good.al).
## Anti Pattern
Dropping `SourceTableTemporary = true` from an API page to "simplify" it, without revisiting the access pattern. The page begins issuing real SQL on every request; locks now contend with other writers; bulk integrations slow proportionally. The same trap exists for a record that was `TableType = Temporary` and gets demoted to a persistent table to make a debugger view easier.
See sample: `do-not-remove-sourcetabletemporary-from-api-page.bad.al`.
See sample: [`do-not-remove-sourcetabletemporary-from-api-page.bad.al`](do-not-remove-sourcetabletemporary-from-api-page.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
Use `FindSet(true)` only when the loop body genuinely modifies the iterated rows; use `FindSet()` (or `FindSet(false)`) when the loop only reads. Do not write `FindSet(true, true)` or `FindSet(true, false)` — the two-parameter form is the obsolete signature.
See sample: `findset-true-applies-updlock-on-read.good.al`.
See sample: [`findset-true-applies-updlock-on-read.good.al`](findset-true-applies-updlock-on-read.good.al).
## Anti Pattern
`FindSet(true)` on a loop that does not modify the iterated rows takes an `UpdLock` the work does not need; competing readers and writers stall against a lock the loop never uses. The mirror anti-pattern is `FindSet()` (no parameter) on a loop that *does* modify each row — the read takes a shared lock, the `Modify` then needs to upgrade, and the gap between them is a deadlock candidate.
See sample: `findset-true-applies-updlock-on-read.bad.al`.
See sample: [`findset-true-applies-updlock-on-read.bad.al`](findset-true-applies-updlock-on-read.bad.al).

View file

@ -17,10 +17,10 @@ A FlowField is computed by SQL on demand. CodeCop AA0232 — "FlowFields should
When introducing or changing a FlowField, walk the `CalcFormula`'s `WHERE` clause field by field and verify the source table has a key whose key fields cover those filters, with the aggregated field in `SumIndexFields`. The same applies when the destination side of the FlowField filter is a list-page column: the page filter triggers the FlowField on every visible row, and only SIFT keeps that affordable.
See sample: `flowfield-source-key-needs-sumindexfields.good.al`.
See sample: [`flowfield-source-key-needs-sumindexfields.good.al`](flowfield-source-key-needs-sumindexfields.good.al).
## Anti Pattern
A `sum` FlowField against a large source table with no matching SIFT key. Each calculation aggregates rows directly; on a ledger-sized source the FlowField becomes the slowest column on every page that displays it. Pointing an existing FlowField's `CalcFormula` at a larger source table without verifying the new source's keys is the same trap a step removed — the upstream review guidance flags it as "CalcFormula changed to larger source table".
See sample: `flowfield-source-key-needs-sumindexfields.bad.al`.
See sample: [`flowfield-source-key-needs-sumindexfields.bad.al`](flowfield-source-key-needs-sumindexfields.bad.al).

View file

@ -17,10 +17,10 @@ Event subscribers fire on every event matching their signature — for `OnAfterV
Open the subscriber with an in-memory predicate that filters out the calls the subscriber does not handle — record type, document type, status, parameter-passed flags. Only after the cheap guard passes should the body issue a database call, and only with `SetLoadFields` for the columns the body actually reads.
See sample: `guard-event-subscribers-before-db-call.good.al`.
See sample: [`guard-event-subscribers-before-db-call.good.al`](guard-event-subscribers-before-db-call.good.al).
## Anti Pattern
`[EventSubscriber(...'OnAfterValidateEvent', 'Quantity', ...)] local procedure ... var Item: Record Item; begin Item.Get(Rec."No."); if Item.HasCustomPricing() then ...;` — `Item.Get` runs on every quantity change, including changes to lines whose `Type` is not `Item`. A pre-check `if Rec.Type <> Rec.Type::Item then exit;` ahead of the `Get` removes most of the calls.
See sample: `guard-event-subscribers-before-db-call.bad.al`.
See sample: [`guard-event-subscribers-before-db-call.bad.al`](guard-event-subscribers-before-db-call.bad.al).

View file

@ -19,10 +19,10 @@ Pages exposed as OData, including Edit in Excel, still run AL page triggers for
Wrap UI-only work — FactBox refresh, notifications, defaulting that is not part of the web-service contract — in `if GuiAllowed then`. Keep the OData path to field values the API actually returns.
See sample: `guiallowed-guard-on-pages-used-as-odata.good.al`.
See sample: [`guiallowed-guard-on-pages-used-as-odata.good.al`](guiallowed-guard-on-pages-used-as-odata.good.al).
## Anti Pattern
Unconditional FactBox or calculation logic in `OnAfterGetRecord` / `OnAfterGetCurrRecord` on a page that is published as a web service or used with Edit in Excel. The signal is trigger work that calls `CurrPage` parts or extra queries without a `GuiAllowed` guard.
See sample: `guiallowed-guard-on-pages-used-as-odata.bad.al`.
See sample: [`guiallowed-guard-on-pages-used-as-odata.bad.al`](guiallowed-guard-on-pages-used-as-odata.bad.al).

View file

@ -17,10 +17,10 @@ By default, a FlowField used directly as a page control's source is calculated w
On BC 26 and later, enable and verify the visible-only FlowField feature before relying on `Visible` to suppress calculation. When the target environment does not guarantee that option, avoid binding an expensive FlowField directly to a usually-hidden control: calculate it only in the branch that displays it and bind the page control to a variable. Do not flag a hidden FlowField when the v26 feature is known to be enabled or the FlowField is cheap and intentionally preloaded.
See sample: `hidden-flowfields-still-calculate-before-bc26-opt-in.good.al`.
See sample: [`hidden-flowfields-still-calculate-before-bc26-opt-in.good.al`](hidden-flowfields-still-calculate-before-bc26-opt-in.good.al).
## Anti Pattern
Adding a costly Sum or Lookup FlowField to a page with `Visible = SomeRareMode` and assuming the hidden state prevents its query on all supported versions. The review signal is the direct FlowField source plus conditional or false visibility, not visibility alone.
See sample: `hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al`.
See sample: [`hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al`](hidden-flowfields-still-calculate-before-bc26-opt-in.bad.al).

View file

@ -21,10 +21,10 @@ Defer the HTTP call to a separate session. When the external operation must corr
A directly created scheduled task is suitable only when its work is independent of the caller's commit. An immediately ready task can run concurrently with the caller, so it must not assume that the caller's writes are already committed. Do **not** use `Commit()` as a general remedy: it irrevocably commits all prior writes in the current transaction, so any subsequent failure cannot roll them back. `Commit()` is appropriate only at top-level entry points where partial persistence is intentional and understood.
See sample: `httpclient-inside-write-transaction-holds-locks.good.al`.
See sample: [`httpclient-inside-write-transaction-holds-locks.good.al`](httpclient-inside-write-transaction-holds-locks.good.al).
## Anti Pattern
`Modify`/`Insert` followed by `HttpClient` in the same procedure with no `Commit` between them. Detection signal: any `HttpClient` use after a write on the same execution path, especially in posting, page actions, or subscribers.
See sample: `httpclient-inside-write-transaction-holds-locks.bad.al`.
See sample: [`httpclient-inside-write-transaction-holds-locks.bad.al`](httpclient-inside-write-transaction-holds-locks.bad.al).

View file

@ -19,10 +19,10 @@ application-area: [all]
When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set.
See sample: `isempty-before-findset-is-extra-round-trip.good.al`.
See sample: [`isempty-before-findset-is-extra-round-trip.good.al`](isempty-before-findset-is-extra-round-trip.good.al).
## Anti Pattern
`if not Rec.IsEmpty() then if Rec.FindSet() then repeat`. Also a false-positive review comment that asks to add that guard. The second read does not avoid the first; it duplicates it.
See sample: `isempty-before-findset-is-extra-round-trip.bad.al`.
See sample: [`isempty-before-findset-is-extra-round-trip.bad.al`](isempty-before-findset-is-extra-round-trip.bad.al).

View file

@ -19,10 +19,10 @@ When a known input determines which fields a subsequent record read will use, a
Call `SetLoadFields` with the common fields. In each branch, call `AddLoadFields` with that branch's normal fields and then perform the record read. This applies only when the discriminator is known before the read; branching on a field from an already-loaded row is too late to tailor that row's initial SQL projection.
See sample: `load-common-fields-before-branching-on-case.good.al`.
See sample: [`load-common-fields-before-branching-on-case.good.al`](load-common-fields-before-branching-on-case.good.al).
## Anti Pattern
A single top-level `SetLoadFields` enumerating every branch's fields, or a branch-local `SetLoadFields` that accidentally discards the common selection. Both make the declared load plan differ from the fields the selected path actually uses.
See sample: `load-common-fields-before-branching-on-case.bad.al`.
See sample: [`load-common-fields-before-branching-on-case.bad.al`](load-common-fields-before-branching-on-case.bad.al).

View file

@ -19,10 +19,10 @@ Work that uses a record only for its identity — passing it to another procedur
When the iterating code's body touches only primary key fields (or passes the record to another procedure that will apply its own `SetLoadFields`), declare `SetLoadFields` with just the primary key fields before applying filters and calling `FindSet`. Callers downstream that need more fields issue their own `Get` or extend the load explicitly.
See sample: `load-only-primary-key-fields-for-reference-work.good.al`.
See sample: [`load-only-primary-key-fields-for-reference-work.good.al`](load-only-primary-key-fields-for-reference-work.good.al).
## Anti Pattern
Using the default full-record load in loops whose body only reads the primary key, or forwards the record to another codeunit that immediately re-queries. The non-key payload is fetched across the wire and held in memory for the duration of the loop, then discarded unread.
See sample: `load-only-primary-key-fields-for-reference-work.bad.al`.
See sample: [`load-only-primary-key-fields-for-reference-work.bad.al`](load-only-primary-key-fields-for-reference-work.bad.al).

View file

@ -17,7 +17,7 @@ application-area: [all]
When changing a key property to `MaintainSQLIndex = false`, find every FlowField whose `CalcFormula` filters on that key and verify another key covers the same fields. When adding a FlowField whose source table has only a `MaintainSQLIndex = false` key for its filter columns, add a fully-indexed key (or accept that the FlowField cannot ride SIFT and reshape the design — see `flowfield-source-key-needs-sumindexfields.md`).
See sample: `maintainsqlindex-false-breaks-flowfield-sift.bad.al`.
See sample: [`maintainsqlindex-false-breaks-flowfield-sift.bad.al`](maintainsqlindex-false-breaks-flowfield-sift.bad.al).
## Anti Pattern

View file

@ -19,10 +19,10 @@ application-area: [all]
Keep company-open subscribers to cheap in-memory work: set a flag, enqueue a job-queue entry, or `TaskScheduler.CreateTask`. Perform HTTP and large SQL after the session is running, in that background work.
See sample: `oncompanyopen-subscribers-must-not-do-io.good.al`.
See sample: [`oncompanyopen-subscribers-must-not-do-io.good.al`](oncompanyopen-subscribers-must-not-do-io.good.al).
## Anti Pattern
An `OnAfterLogin` / `OnCompanyOpenCompleted` subscriber that calls `HttpClient` or scans a ledger. Detection signal: `HttpClient`, `FindSet`, or `CalcFields` inside a subscriber bound to those events.
See sample: `oncompanyopen-subscribers-must-not-do-io.bad.al`.
See sample: [`oncompanyopen-subscribers-must-not-do-io.bad.al`](oncompanyopen-subscribers-must-not-do-io.bad.al).

View file

@ -19,10 +19,10 @@ AL documentation does not guarantee that a `case` statement uses a linear compar
After profiling confirms the comparison path matters and the runtime frequency is known, list common branches first without changing the set of handled values, fallback behavior, or branch bodies.
See sample: `order-case-branches-by-frequency.good.al`.
See sample: [`order-case-branches-by-frequency.good.al`](order-case-branches-by-frequency.good.al).
## Anti Pattern
Reordering branches based on assumed frequency without profiling, or changing an `else` arm or handled value while making the optimization. The good and bad forms must differ only in branch order.
See sample: `order-case-branches-by-frequency.bad.al`.
See sample: [`order-case-branches-by-frequency.bad.al`](order-case-branches-by-frequency.bad.al).

View file

@ -19,10 +19,10 @@ Role-center cues and CardPart totals that run `CalcFields`, scans, or HTTP on th
Bind the cue to a page variable, enqueue a read-only calculation from `OnAfterGetCurrRecord` (not `OnAfterGetRecord` on a list), and apply the result in `OnPageBackgroundTaskCompleted`. Show a placeholder until then.
See sample: `page-background-tasks-for-expensive-cues.good.al`.
See sample: [`page-background-tasks-for-expensive-cues.good.al`](page-background-tasks-for-expensive-cues.good.al).
## Anti Pattern
`CalcFields` or a ledger `Count` in `OnOpenPage` / `OnAfterGetCurrRecord` of a CueGroup CardPart with no background task. The Role Center waits on SQL the user may never look at.
See sample: `page-background-tasks-for-expensive-cues.bad.al`.
See sample: [`page-background-tasks-for-expensive-cues.bad.al`](page-background-tasks-for-expensive-cues.bad.al).

View file

@ -17,10 +17,10 @@ Two CodeCop rules carve out the loop pattern. AA0181 says `FindSet()`/`Find()` "
When the body executes `repeat ... until Next() = 0;`, open the iteration with `FindSet()`. When the body needs one record and does not call `Next`, use `FindFirst`, `FindLast`, or — if the full primary key is known — `Get` (see `use-get-instead-of-findfirst-on-full-primary-key.md`). The choice is per call site, not a global preference.
See sample: `pair-findset-with-next-loop.good.al`.
See sample: [`pair-findset-with-next-loop.good.al`](pair-findset-with-next-loop.good.al).
## Anti Pattern
`if Customer.FindFirst() then repeat ... until Customer.Next() = 0;` — AA0233 flags this. The single-row API does not prepare the runtime for iteration, so the loop pays a cost the FindSet path does not. The mirror anti-pattern is calling `FindSet` to read a single record (see `use-isempty-for-existence-check.md` when only existence is required).
See sample: `pair-findset-with-next-loop.bad.al`.
See sample: [`pair-findset-with-next-loop.bad.al`](pair-findset-with-next-loop.bad.al).

View file

@ -17,7 +17,7 @@ application-area: [all]
Reach for the `(false)` form when the calling code already enforces the invariants the trigger would, or when the trigger is empty for the current table/extension. Use `(true)` when the trigger does work the caller depends on (number-series allocation, validation, cascading writes). Decide per call, not by code style: a default of "always `true`" makes bulk writes pay for triggers they did not need, and a default of "always `false`" silently skips validation the trigger was put there to enforce.
See sample: `pass-false-to-insert-when-trigger-not-needed.good.al`.
See sample: [`pass-false-to-insert-when-trigger-not-needed.good.al`](pass-false-to-insert-when-trigger-not-needed.good.al).
## Anti Pattern

View file

@ -19,10 +19,10 @@ A `FindSet`/`Next` loop builds an enumerator from the fields selected for load.
Helpers that read extra fields on an in-flight iterator must take the record as `var`, or the caller must `AddLoadFields` those fields before the loop. Prefer declaring the extra fields up front so no JIT is needed.
See sample: `pass-var-record-to-preserve-partial-load-enumerator.good.al`.
See sample: [`pass-var-record-to-preserve-partial-load-enumerator.good.al`](pass-var-record-to-preserve-partial-load-enumerator.good.al).
## Anti Pattern
A `SetLoadFields` loop that passes the iterator by value into a helper which then reads a field that was not loaded. The first row pays one JIT; every subsequent row pays it again because the enumerator never learned the extra field.
See sample: `pass-var-record-to-preserve-partial-load-enumerator.bad.al`.
See sample: [`pass-var-record-to-preserve-partial-load-enumerator.bad.al`](pass-var-record-to-preserve-partial-load-enumerator.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
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`.
See sample: [`prefer-modifyall-over-per-row-modify.good.al`](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 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`.
See sample: [`prefer-modifyall-over-per-row-modify.bad.al`](prefer-modifyall-over-per-row-modify.bad.al).

View file

@ -17,10 +17,10 @@ Without read scale-out, `LockTable` causes subsequent reads of that table in the
For a read-only operation that specifically requires committed data, set `Rec.ReadIsolation := IsolationLevel::ReadCommitted` immediately before the read. If the default isolation is sufficient, set neither property. `ReadCommitted` can still block behind writers and does not guarantee that repeated reads stay unchanged; use the isolation level required by the operation. Reserve update locks for read-before-write logic, not read-only helpers.
See sample: `prefer-readisolation-over-locktable-for-reads.good.al`.
See sample: [`prefer-readisolation-over-locktable-for-reads.good.al`](prefer-readisolation-over-locktable-for-reads.good.al).
## Anti Pattern
`Rec.LockTable();` at the top of a helper that only reads, perhaps to "make sure the read is consistent". It takes stronger isolation than the helper needs and changes later reads of that table in the surrounding transaction or read-scale-out session.
See sample: `prefer-readisolation-over-locktable-for-reads.bad.al`.
See sample: [`prefer-readisolation-over-locktable-for-reads.bad.al`](prefer-readisolation-over-locktable-for-reads.bad.al).

View file

@ -20,10 +20,10 @@ Since v23, all extensions on the same base table share at most one companion-tab
Put optional, sparse, or integration attributes in a related table with the ledger entry number as primary key. Show them from a FactBox or a FlowField.
Use a tableextension stored field only when the value must appear as a native list column and is read on almost every access.
See sample: `prefer-related-table-over-extension-on-hot-ledgers.good.al`.
See sample: [`prefer-related-table-over-extension-on-hot-ledgers.good.al`](prefer-related-table-over-extension-on-hot-ledgers.good.al).
## Anti Pattern
`tableextension` on `"G/L Entry"` (or another posting table) that adds several stored `Text`/`Blob` fields used only by one integration. The companion join is paid on every posting and on any AL code path that loads extension fields, even when those columns are not needed for the current operation.
See sample: `prefer-related-table-over-extension-on-hot-ledgers.bad.al`.
See sample: [`prefer-related-table-over-extension-on-hot-ledgers.bad.al`](prefer-related-table-over-extension-on-hot-ledgers.bad.al).

View file

@ -19,10 +19,10 @@ The Business Central server caches primary-key `Get` calls within a transaction.
Keep `Record.Get` for repeated lookups of the same primary keys in one transaction. Use a Query when the work is a true join or aggregation that the record API would express as nested scans. Do not flag a guarded `Get` on a repeating key as an N+1 solely because a Query could express the same columns.
See sample: `query-results-bypass-primary-key-cache.good.al`.
See sample: [`query-results-bypass-primary-key-cache.good.al`](query-results-bypass-primary-key-cache.good.al).
## Anti Pattern
Rewriting a helper that `Get`s Customer by `No.` on every sales line into a Query opened inside that helper. Distinct line customers still need a lookup; repeating customers were already served from the PK cache. The Query pays SQL every time.
See sample: `query-results-bypass-primary-key-cache.bad.al`.
See sample: [`query-results-bypass-primary-key-cache.bad.al`](query-results-bypass-primary-key-cache.bad.al).

View file

@ -19,10 +19,10 @@ application-area: [all]
Call `Reset` (or empty `SetLoadFields()`) first when the variable must be reused, then call `SetLoadFields` with the fields the next read actually uses, then apply filters and read. After `Reset`, a new `SetLoadFields` is required; the previous list is gone.
See sample: `reset-clears-partial-record-selection.good.al`.
See sample: [`reset-clears-partial-record-selection.good.al`](reset-clears-partial-record-selection.good.al).
## Anti Pattern
`SetLoadFields(...)` followed by `Reset()` (or by parameterless `SetLoadFields()`) and then `FindSet` without restoring the load list. The filters look correct; the SQL still selects every column.
See sample: `reset-clears-partial-record-selection.bad.al`.
See sample: [`reset-clears-partial-record-selection.bad.al`](reset-clears-partial-record-selection.bad.al).

View file

@ -26,10 +26,10 @@ Decide `SetCurrentKey` on one question only: **do I need the result set in a spe
To make a filtered read fast, ensure a key (index) exists on the table whose leading fields cover the filter, and filter on those fields with `SetRange`/`SetFilter`. That is what lets the optimizer seek. Defining the key creates the index; `SetCurrentKey` is not required to make the optimizer use it.
See sample: `setcurrentkey-sets-sort-order-not-index-hint.good.al`.
See sample: [`setcurrentkey-sets-sort-order-not-index-hint.good.al`](setcurrentkey-sets-sort-order-not-index-hint.good.al).
## Anti Pattern
Adding `SetCurrentKey` to a filtered read purely in the belief that it forces SQL Server to seek a particular index, when the code never uses the resulting order. This does nothing for index selection and only appends an `ORDER BY` the query does not need, risking an unnecessary sort. Remove the `SetCurrentKey`; rely on the filters and an existing covering key instead.
See sample: `setcurrentkey-sets-sort-order-not-index-hint.bad.al`.
See sample: [`setcurrentkey-sets-sort-order-not-index-hint.bad.al`](setcurrentkey-sets-sort-order-not-index-hint.bad.al).

View file

@ -19,10 +19,10 @@ application-area: [all]
Omit `SetLoadFields` on loops whose body performs a documented full-load operation (`Insert`, `Delete`, `Rename`, `TransferFields`, or assignment into a temporary record) on the same record variable, so the initial read already materializes every field those operations need.
See sample: `skip-setloadfields-on-write-and-transferfields.good.al`.
See sample: [`skip-setloadfields-on-write-and-transferfields.good.al`](skip-setloadfields-on-write-and-transferfields.good.al).
## Anti Pattern
Calling `SetLoadFields` immediately before a `FindSet` whose body performs `Delete`, `Rename`, `TransferFields`, or copies the record into a temporary table. The review signal is a partial-record setup on a record variable that feeds one of these documented full-load operations in the same iteration.
See sample: `skip-setloadfields-on-write-and-transferfields.bad.al`.
See sample: [`skip-setloadfields-on-write-and-transferfields.bad.al`](skip-setloadfields-on-write-and-transferfields.bad.al).

View file

@ -19,10 +19,10 @@ A `TableRelation` lookup opens the table's `LookupPageId`. If that is the full l
Give master tables a slim lookup page (`PageType = List`, few columns, no FactBoxes, no heavy `OnAfterGetRecord`) and assign it to `LookupPageId`. Keep the full list for `DrillDownPageId` and the role-explorer entry.
See sample: `use-dedicated-lookup-pages-not-full-lists.good.al`.
See sample: [`use-dedicated-lookup-pages-not-full-lists.good.al`](use-dedicated-lookup-pages-not-full-lists.good.al).
## Anti Pattern
`LookupPageId = Page::"... List"` on a table that already has (or should have) a lookup page. Opening a field lookup then pays list-page cost. The signal is `LookupPageId` pointing at a page that declares FactBoxes or a wide repeater.
See sample: `use-dedicated-lookup-pages-not-full-lists.bad.al`.
See sample: [`use-dedicated-lookup-pages-not-full-lists.bad.al`](use-dedicated-lookup-pages-not-full-lists.bad.al).

View file

@ -19,10 +19,10 @@ application-area: [all]
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`.
See sample: [`use-deleteall-for-filtered-bulk-deletion.good.al`](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 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`.
See sample: [`use-deleteall-for-filtered-bulk-deletion.bad.al`](use-deleteall-for-filtered-bulk-deletion.bad.al).

Some files were not shown because too many files have changed in this diff Show more