Extract 55 knowledge articles from BC review-agent prompt

Adds 55 articles (plus 76 code samples) spanning four new domains and
two existing domains, extracted from the internal Business Central
review-agent prompt. Content was filtered against BCQuality's
remedial-knowledge premise: each article encodes BC-specific behaviour,
a CodeCop rule, a platform API semantic, or an anti-false-positive
guideline that a capable LLM would otherwise get wrong.

New domains:
- privacy (11 articles): DataClassification inheritance semantics, the
  StrSubstNo-defeats-Error-telemetry-classification pitfall, Privacy
  Notice consent for outgoing requests, anti-false-positives for pages
  and in-memory data.
- upgrade (11 articles): upgrade-codeunit structure, upgrade-tag
  lifecycle and registration, protected DB reads, DataTransfer for
  large datasets, InitValue semantics, enum-ordinal preservation,
  obsolete-workflow, first-install detection.
- ui (9 articles): caption capitalization by phrase type, tooltip voice,
  teaching-tip vs tooltip, tour-tip conventions, character limits,
  banned terms, ampersand handling, title punctuation.
- style (11 articles): label-suffix convention, API page naming,
  temporary-variable prefix, label properties (Comment/Locked), named
  invocations, FieldCaption in user messages, OptionCaption pairing,
  Error-parameter passing, `this` keyword, required parentheses, file
  naming.

Gaps in existing domains:
- performance (11 articles): production-scale table catalog (no row
  counts, per internal-data concern), anti-false-positive for bounded
  tables, guard-before-Get ordering, redundant-Get-in-OnAfterGetRecord,
  LockTable in read-only helpers, combined ModifyAll passes, writes in
  OnAfterGetRecord, SetLoadFields heuristics, temporary-table
  regressions, FlowField source-table widening, MaintainSQLIndex
  disabling SIFT.
- security (2 articles): environment-specific hardcoded GUIDs,
  ValidateTableRelation=false on user input.

Intentionally excluded: specific production P95 row-count numbers
(aggregated internal telemetry); rewritten as categorical guidance on
which tables to treat as production-scale without publishing sizes.

All articles use `bc-version: [all]` (applies to every BC version, per
the new schema sentinel). Validator passes with 0 errors / 0 warnings.
This commit is contained in:
Jesper Schulz-Wedde 2026-04-23 16:43:42 +02:00
parent 9a4198eb28
commit e570d6113f
131 changed files with 2799 additions and 0 deletions

View file

@ -0,0 +1,14 @@
codeunit 50801 "Upgrade Sample CallMethods Bad"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
var
Customer: Record Customer;
begin
// Inline logic in the trigger body: no tag guard, not testable in isolation,
// re-runs on every upgrade.
Customer.SetRange(Blocked, Customer.Blocked::" ");
Customer.ModifyAll("Some Field", true);
end;
}

View file

@ -0,0 +1,31 @@
codeunit 50800 "Upgrade Sample CallMethods Good"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeCustomerDefaults();
UpgradeSalesDocumentDefaults();
end;
local procedure UpgradeCustomerDefaults()
var
UpgradeTag: Codeunit "Upgrade Tag";
begin
if UpgradeTag.HasUpgradeTag(CustomerDefaultsUpgradeTag()) then
exit;
// Step body omitted
UpgradeTag.SetUpgradeTag(CustomerDefaultsUpgradeTag());
end;
local procedure UpgradeSalesDocumentDefaults()
begin
end;
local procedure CustomerDefaultsUpgradeTag(): Code[250]
begin
exit('MS-000001-CustomerDefaults-20260501');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [upgrade-codeunit, onupgradepercompany, onupgradeperdatabase, structure]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Call named methods from OnUpgrade triggers; keep the triggers empty of logic
## Description
An upgrade codeunit (`Subtype = Upgrade`) runs its triggers once per upgrade scope. Inlining upgrade logic inside the trigger body mixes the entry point with the work, makes individual steps untestable in isolation, and prevents the standard upgrade-tag guard pattern from being applied cleanly. The convention across Business Central's own upgrade codeunits is that `OnUpgradePerCompany` and `OnUpgradePerDatabase` are a list of calls to named local procedures, each implementing one step behind its own upgrade-tag check.
## Best Practice
Keep `OnUpgradePerCompany` and `OnUpgradePerDatabase` to a list of `UpgradeXxx();` statements. Put every data migration, default, or correction in a named local procedure whose first action is the upgrade-tag guard. Empty trigger bodies are also acceptable as placeholders on a new codeunit with no current steps.
See sample: `call-methods-from-onupgrade-triggers-not-inline-code.good.al`.
## Anti Pattern
Writing `Customer.ModifyAll(...)`, `TableX.SetRange(...)` + loops, or `DataTransfer.CopyFields()` directly inside the trigger body. The step is untagged, untestable, and re-runs on every upgrade.
See sample: `call-methods-from-onupgrade-triggers-not-inline-code.bad.al`.

View file

@ -0,0 +1,15 @@
codeunit 50822 "Upgrade Sample FirstInstall Bad"
{
Subtype = Install;
trigger OnInstallAppPerCompany()
begin
// Unconditional initialization. Re-install after uninstall either throws
// on primary-key collisions or overwrites existing rows.
InsertDefaultSetup();
end;
local procedure InsertDefaultSetup()
begin
end;
}

View file

@ -0,0 +1,20 @@
codeunit 50821 "Upgrade Sample FirstInstall Good"
{
Subtype = Install;
trigger OnInstallAppPerCompany()
var
AppInfo: ModuleInfo;
begin
NavApp.GetCurrentModuleInfo(AppInfo);
if AppInfo.DataVersion() <> Version.Create('0.0.0.0') then
exit;
// First-install-only initialization follows here.
InsertDefaultSetup();
end;
local procedure InsertDefaultSetup()
begin
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [oninstall, dataversion, appinfo, first-install, upgrade-tag]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Detect first install via DataVersion equal to 0.0.0.0 in OnInstall triggers
## Description
`OnInstallAppPerCompany` fires on first install and on subsequent re-installs after an uninstall. Code that should only run on the very first install needs to distinguish the two — and the supported way is checking `AppInfo.DataVersion() = Version.Create('0.0.0.0')`, which is the sentinel for "no prior data exists for this app in this tenant". This is the one case where a DataVersion check is correct; steady-state upgrade steps should use upgrade tags instead.
## Best Practice
In `OnInstallAppPerCompany`, call `NavApp.GetCurrentModuleInfo(AppInfo)` and exit early when `AppInfo.DataVersion()` is non-zero. The remainder of the trigger body then runs exclusively on first install. For all other version-sensitive upgrade logic, use upgrade tags (see `use-upgrade-tags-not-version-checks`).
See sample: `detect-first-install-via-dataversion-zero.good.al`.
## Anti Pattern
Running initialization unconditionally in `OnInstallAppPerCompany` and relying on primary-key collisions to avoid double-inserts. Re-install scenarios either throw or overwrite existing rows; the install path becomes brittle as the app grows.
See sample: `detect-first-install-via-dataversion-zero.bad.al`.

View file

@ -0,0 +1,22 @@
---
bc-version: [all]
domain: upgrade
keywords: [upgrade, httpclient, external-service, dotnet, availability]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not make external service calls inside upgrade codeunits
## Description
The upgrade scope has to complete for the tenant to reach the new version. Any call in the upgrade path that depends on an external service — HttpClient to a partner API, a DotNet interop call, a codeunit that fetches remote configuration — fails closed when the service is unreachable, misconfigured, or slow. The failure blocks the upgrade for every customer whose environment cannot reach the dependency at the moment the upgrade runs, and there is no user present to retry. The scope is specifically code inside codeunits with `Subtype = Upgrade` or reachable from their triggers.
## Best Practice
Defer external calls to runtime code that executes after the upgrade — install-triggered tasks, background job queue entries scheduled by the upgrade, or lazy initialization on first use. The upgrade step should compute a local result or mark work to be done, not perform the remote call itself.
## Anti Pattern
`HttpClient.Get(...)` or `DotNetType.CallStaticMethod(...)` directly in `OnUpgradePerCompany`, or in a local procedure called from it. The upgrade now depends on network availability to a service the platform does not control.

View file

@ -0,0 +1,23 @@
enum 50815 "Upgrade Sample EnumInsert Bad"
{
Extensible = true;
value(0; First) { Caption = 'First'; }
// Inserting at ordinal 1 shifts everything below. Every row that stored
// ordinal 1 before now resolves to NewMiddleValue.
value(1; NewMiddleValue) { Caption = 'New middle value'; }
value(2; Second) { Caption = 'Second'; }
value(3; Third) { Caption = 'Third'; }
}
enum 50816 "Upgrade Sample EnumRemove Bad"
{
Extensible = true;
value(0; First) { Caption = 'First'; }
// value(1; Second) removed without obsoletion.
// Existing rows storing ordinal 1 no longer resolve to any declared value.
value(2; Third) { Caption = 'Third'; }
}

View file

@ -0,0 +1,29 @@
enum 50813 "Upgrade Sample EnumAdditive Good"
{
Extensible = true;
value(0; First) { Caption = 'First'; }
value(1; Second) { Caption = 'Second'; }
value(2; Third) { Caption = 'Third'; }
// New value appended at the next free ordinal. Existing stored ordinals
// (0, 1, 2) keep their meaning.
value(3; NewValue) { Caption = 'New value'; }
}
enum 50814 "Upgrade Sample EnumRetire Good"
{
Extensible = true;
value(0; First) { Caption = 'First'; }
value(1; Second)
{
Caption = 'Second';
ObsoleteState = Removed;
ObsoleteReason = 'Replaced by NewValue.';
ObsoleteTag = '28.0';
}
value(2; Third) { Caption = 'Third'; }
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [enum, ordinal, obsolete, backward-compatibility, breaking-change]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Enum changes must be additive at the end; never insert or remove values
## Description
AL enums store their ordinal on disk. Inserting a new value in the middle of an existing enum shifts every following ordinal by one: every row whose field holds the old ordinal N now resolves to the value that used to be N+1. Removing a value without obsoletion has the same effect. Both changes are data corruption disguised as a code edit and are effectively irreversible once a tenant has upgraded. Adding values at the end is safe — existing ordinals keep their meaning.
## Best Practice
Append new enum values at the end, taking the next free ordinal. When a value must be retired, mark it with `ObsoleteState = Removed`, `ObsoleteReason`, and `ObsoleteTag` so tooling and downstream code can detect the deprecation; do not reclaim the ordinal. Renaming the caption on an existing ordinal is fine.
See sample: `enum-changes-must-be-additive-at-the-end.good.al`.
## Anti Pattern
Inserting `value(1; "NewMiddleValue")` between existing `value(0; "First")` and the original `value(1; "Second")`. Every row that stored ordinal 1 before the change now reads as `NewMiddleValue`. The same applies to removing a value outright without obsoletion.
See sample: `enum-changes-must-be-additive-at-the-end.bad.al`.

View file

@ -0,0 +1,19 @@
codeunit 50807 "Upgrade Sample GuardReads Bad"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
var
Setup: Record "Sales & Receivables Setup";
Customer: Record Customer;
begin
// Unguarded Get. One tenant whose Setup row is missing blocks the upgrade.
Setup.Get();
// Unguarded FindSet. Raises when the table is empty for this tenant.
Customer.FindSet();
repeat
// per-row work
until Customer.Next() = 0;
end;
}

View file

@ -0,0 +1,23 @@
codeunit 50806 "Upgrade Sample GuardReads Good"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeDefaults();
end;
local procedure UpgradeDefaults()
var
Setup: Record "Sales & Receivables Setup";
Customer: Record Customer;
begin
if not Setup.Get() then
exit;
if Customer.FindSet() then
repeat
// per-row work
until Customer.Next() = 0;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [upgrade, get, findset, findlast, guard, unblocking]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Guard every database read in upgrade codeunits; never let a missing row block the upgrade
## Description
An unguarded `Record.Get()` raises when the row does not exist; an unguarded `FindSet()` or `FindLast()` raises when the result set is empty. In ordinary runtime code those errors surface to a user who can retry. In an upgrade codeunit they abort the upgrade of the tenant and the customer is blocked from getting to the new version. Real-world data is inconsistent enough — missing lookup rows, empty setup tables, skipped modules — that an unguarded read reliably blocks at least one customer per release.
## Best Practice
Wrap every Get, FindSet, FindFirst, FindLast, and related call in an `if … then` guard. On the not-found path, either exit the current step or log telemetry and continue; never let the upgrade scope raise. `if Customer.FindSet() then;` (statement terminator as the entire body) is an acceptable pattern when only the side effect of positioning matters.
See sample: `guard-every-database-read-in-upgrade-codeunits.good.al`.
## Anti Pattern
`Customer.Get(CustomerNo);` or `SalesHeader.FindLast();` inside an upgrade procedure. One missing row in one tenant turns every future upgrade into a support ticket.
See sample: `guard-every-database-read-in-upgrade-codeunits.bad.al`.

View file

@ -0,0 +1,14 @@
tableextension 50812 "Upgrade Sample InitValue Bad" extends Customer
{
fields
{
field(50101; "Is Active"; Boolean)
{
DataClassification = CustomerContent;
Caption = 'Is active';
// InitValue applies to new records only.
// Every existing customer remains Is Active = false after the upgrade.
InitValue = true;
}
}
}

View file

@ -0,0 +1,43 @@
tableextension 50810 "Upgrade Sample InitValue Good" extends Customer
{
fields
{
field(50100; "Is Active"; Boolean)
{
DataClassification = CustomerContent;
Caption = 'Is active';
InitValue = true;
}
}
}
codeunit 50811 "Upgrade Sample InitValue Good Upg"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeExistingCustomersIsActive();
end;
local procedure UpgradeExistingCustomersIsActive()
var
Customer: Record Customer;
CustomerDataTransfer: DataTransfer;
UpgradeTag: Codeunit "Upgrade Tag";
begin
if UpgradeTag.HasUpgradeTag(UpgradeCustomerIsActiveTag()) then
exit;
CustomerDataTransfer.SetTables(Database::Customer, Database::Customer);
CustomerDataTransfer.AddConstantValue(true, Customer.FieldNo("Is Active"));
CustomerDataTransfer.CopyFields();
UpgradeTag.SetUpgradeTag(UpgradeCustomerIsActiveTag());
end;
local procedure UpgradeCustomerIsActiveTag(): Code[250]
begin
exit('MS-000006-CustomerIsActive-20260501');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [initvalue, field, upgrade, existing-records, migration]
technologies: [al]
countries: [w1]
application-area: [all]
---
# InitValue on a new field does not populate existing rows
## Description
The `InitValue` property sets a field's default for rows created after the field exists. Rows that already exist when the field is added keep the data-type default (empty text, zero, false, epoch date) — InitValue does not retroactively apply. Shipping a new field with `InitValue = true` on an existing table produces a silently inconsistent dataset: new rows match the intended default, existing rows do not, and callers that do not distinguish the two read the wrong state for existing data.
## Best Practice
When adding a field to an existing table with a meaningful default, write an upgrade step that populates existing rows with the same value, guarded by its own upgrade tag. Use `DataTransfer` with `AddConstantValue` for set-based initialization (see `use-datatransfer-for-large-dataset-initialization`). Exceptions: brand-new tables, new Boolean fields where `false` is the correct value for existing rows, and informational fields where empty is an acceptable state.
See sample: `initvalue-does-not-populate-existing-records.good.al`.
## Anti Pattern
Adding `field(100; "Is Active"; Boolean) { InitValue = true; }` to an existing business table without upgrade code. New records are Active; every existing record is silently inactive. The bug surfaces later as "why is this data missing from the default report?"
See sample: `initvalue-does-not-populate-existing-records.bad.al`.

View file

@ -0,0 +1,22 @@
codeunit 50805 "Upgrade Sample TagRegister Bad"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
var
UpgradeTag: Codeunit "Upgrade Tag";
begin
if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then
exit;
UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag());
end;
// Missing OnGetPerCompanyUpgradeTags subscriber.
// The tag is set but the platform's upgrade-tag machinery does not know about it.
local procedure FeatureXUpgradeTag(): Code[250]
begin
exit('MS-000004-FeatureX-20260501');
end;
}

View file

@ -0,0 +1,25 @@
codeunit 50804 "Upgrade Sample TagRegister Good"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
var
UpgradeTag: Codeunit "Upgrade Tag";
begin
if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then
exit;
UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag());
end;
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", 'OnGetPerCompanyUpgradeTags', '', false, false)]
local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]])
begin
PerCompanyUpgradeTags.Add(FeatureXUpgradeTag());
end;
local procedure FeatureXUpgradeTag(): Code[250]
begin
exit('MS-000003-FeatureX-20260501');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [upgrade-tag, ongetpercompanyupgradetags, ongetperdatabaseupgradetags, registration]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Register every upgrade tag with the matching PerCompany or PerDatabase subscriber
## Description
An upgrade tag set via `UpgradeTag.SetUpgradeTag` only participates in the platform's upgrade-tag machinery when it is also registered through `OnGetPerCompanyUpgradeTags` or `OnGetPerDatabaseUpgradeTags` event subscribers on `Codeunit "Upgrade Tag"`. Without registration, the platform cannot enumerate the tag for diagnostic reporting, skipped-step detection, or cross-app coordination. The step still runs and sets the tag, but the tag is effectively invisible to the rest of the upgrade infrastructure.
## Best Practice
For every upgrade-tag constant referenced in `HasUpgradeTag`/`SetUpgradeTag`, register it in the subscriber that matches its trigger scope: tags used from `OnUpgradePerCompany` go in `OnGetPerCompanyUpgradeTags`; tags used from `OnUpgradePerDatabase` go in `OnGetPerDatabaseUpgradeTags`. Keep the tag string in a single source (Label or function) and reference it at the guard, the setter, and the registration.
See sample: `register-upgrade-tags-with-subscribers.good.al`.
## Anti Pattern
Adding a new `UpgradeTag.SetUpgradeTag(MyTag())` without the matching `PerCompanyUpgradeTags.Add(MyTag())` in the registration subscriber. The code compiles and the step completes, but the tag is unregistered and the infrastructure is partially disabled.
See sample: `register-upgrade-tags-with-subscribers.bad.al`.

View file

@ -0,0 +1,14 @@
codeunit 50820 "Upgrade Sample SkipContext Bad"
{
procedure AddReportSelectionEntries()
begin
// No execution-context check. On upgrade, this either throws on
// primary-key conflict or silently overwrites the tenant's
// customized report selections.
InsertDefaultReportSelections();
end;
local procedure InsertDefaultReportSelections()
begin
end;
}

View file

@ -0,0 +1,15 @@
codeunit 50819 "Upgrade Sample SkipContext Good"
{
procedure AddReportSelectionEntries()
begin
// Existing tenants already have the selections, possibly customized.
if GetExecutionContext() = ExecutionContext::Upgrade then
exit;
InsertDefaultReportSelections();
end;
local procedure InsertDefaultReportSelections()
begin
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [executioncontext, upgrade, reportselections, initialization, install]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Skip non-essential initialization when ExecutionContext is Upgrade
## Description
Initialization code that inserts default rows — report selections, number-series, setup-table defaults — is correct on first install and harmful during upgrade. Existing tenants already have these rows, possibly customized; re-running the initialization either fails on primary-key conflicts or silently overwrites customer configuration. The platform exposes `GetExecutionContext()` so the same procedure can be safely called from install and upgrade paths without duplicating the insert logic.
## Best Practice
Check `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` at the top of idempotent-on-install-only procedures. Keep the early exit narrow and document the reason. The check should be additive to existing guards, not a replacement for proper primary-key handling in the insert itself.
See sample: `skip-non-essential-work-during-upgrade-context.good.al`.
## Anti Pattern
A procedure that unconditionally inserts a default report-selection, number-series, or setup row, called from both install and upgrade paths. On upgrade it either throws on the conflicting key or overwrites the tenant's existing configuration.
See sample: `skip-non-essential-work-during-upgrade-context.bad.al`.

View file

@ -0,0 +1,22 @@
codeunit 50809 "Upgrade Sample DataTransfer Bad"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
InitializeNewFlag();
end;
local procedure InitializeNewFlag()
var
CustLedgerEntry: Record "Cust. Ledger Entry";
begin
// Row-at-a-time update over a 10M-row ledger table. Multi-hour upgrade.
CustLedgerEntry.SetRange(Open, true);
if CustLedgerEntry.FindSet(true) then
repeat
CustLedgerEntry."New Flag" := false;
CustLedgerEntry.Modify();
until CustLedgerEntry.Next() = 0;
end;
}

View file

@ -0,0 +1,31 @@
codeunit 50808 "Upgrade Sample DataTransfer Good"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
InitializeNewFlag();
end;
local procedure InitializeNewFlag()
var
CustLedgerEntry: Record "Cust. Ledger Entry";
CLEDataTransfer: DataTransfer;
UpgradeTag: Codeunit "Upgrade Tag";
begin
if UpgradeTag.HasUpgradeTag(InitializeNewFlagTag()) then
exit;
CLEDataTransfer.SetTables(Database::"Cust. Ledger Entry", Database::"Cust. Ledger Entry");
CLEDataTransfer.AddSourceFilter(CustLedgerEntry.FieldNo(Open), '=%1', true);
CLEDataTransfer.AddConstantValue(false, CustLedgerEntry.FieldNo("New Flag"));
CLEDataTransfer.CopyFields();
UpgradeTag.SetUpgradeTag(InitializeNewFlagTag());
end;
local procedure InitializeNewFlagTag(): Code[250]
begin
exit('MS-000005-CLEInitializeNewFlag-20260501');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [datatransfer, initvalue, large-dataset, bulk-update, upgrade]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use DataTransfer to initialize large tables in upgrade; not FindSet plus Modify
## Description
An upgrade that populates a new field on millions of existing rows with a FindSet+Modify loop pays a round-trip and a per-row trigger invocation for every row — turning a multi-hour upgrade into a multi-day one on ledger-entry-scale tables. `DataTransfer` pushes the update to SQL as a single set-based operation using source filters and constant values, which is the supported platform mechanism for this scenario. The tradeoff: DataTransfer bypasses validation triggers and event subscribers — if the step depends on trigger logic, that has to be reconstructed explicitly.
## Best Practice
Use DataTransfer for field-default initialization on existing tables, especially when the target is a ledger-entry or document-line table. Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. When trigger or subscriber behaviour is required, do that work separately against a filtered result set so the bulk update remains set-based.
See sample: `use-datatransfer-for-large-dataset-initialization.good.al`.
## Anti Pattern
`FindSet(true)` + `Modify()` in a loop as the initialization path for a new field across an entire existing table. The resulting upgrade time is proportional to the row count; for a ten-million-row ledger-entry table it is the single largest step in the release.
See sample: `use-datatransfer-for-large-dataset-initialization.bad.al`.

View file

@ -0,0 +1,11 @@
codeunit 50818 "Upgrade Sample Obsolete Bad"
{
// Straight to Removed with no preceding Pending phase, no ObsoleteReason,
// no ObsoleteTag. Dependents compiled against the previous release hit
// a hard compile error with no migration signal.
[Obsolete('', '')]
procedure CalculateNetAmount(Amount: Decimal): Decimal
begin
Error('Removed.');
end;
}

View file

@ -0,0 +1,13 @@
codeunit 50817 "Upgrade Sample Obsolete Good"
{
[Obsolete('Use CalculateNetAmountV2 for the updated rounding semantics.', '28.0')]
procedure CalculateNetAmount(Amount: Decimal): Decimal
begin
exit(Amount);
end;
procedure CalculateNetAmountV2(Amount: Decimal): Decimal
begin
exit(Amount);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [obsolete, obsoletestate, obsoletereason, obsoletetag, deprecation]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Deprecate via ObsoleteState Pending first; move to Removed only after the grace window
## Description
AL's obsolete workflow is two-stage by design. `ObsoleteState = Pending` keeps the object or member compilable and callable but emits warnings and records the deprecation in metadata. `ObsoleteState = Removed` makes it a compile error for callers. Jumping straight to Removed — or marking Pending without `ObsoleteReason` and `ObsoleteTag` — breaks dependents who had no signal to migrate, and loses the tooling's ability to surface the planned removal in sandbox builds before the production tenant upgrades.
## Best Practice
Mark the element `ObsoleteState = Pending` with a concrete `ObsoleteReason` naming the replacement and an `ObsoleteTag` identifying the version the deprecation started. Keep it Pending through at least one major release so dependents have a cycle to migrate. Move to `ObsoleteState = Removed` only in a later release, with the same Reason and Tag retained or updated.
See sample: `use-obsolete-pending-before-removed.good.al`.
## Anti Pattern
`[Obsolete('', '')]` or `ObsoleteState = Removed` applied directly on an element that was public and callable in the previous release, with no preceding Pending phase. Dependents get a hard compile error with no migration signal in the previous version.
See sample: `use-obsolete-pending-before-removed.bad.al`.

View file

@ -0,0 +1,27 @@
codeunit 50803 "Upgrade Sample TagGuard Bad"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
var
AppInfo: ModuleInfo;
begin
NavApp.GetCurrentModuleInfo(AppInfo);
// Version check: fragile across skipped versions, and every nested branch
// is another place a customer can be stuck if the matching step fails.
if AppInfo.DataVersion().Major < 18 then
UpgradeFeatureA()
else
if AppInfo.DataVersion().Major < 21 then
UpgradeFeatureB();
end;
local procedure UpgradeFeatureA()
begin
end;
local procedure UpgradeFeatureB()
begin
end;
}

View file

@ -0,0 +1,26 @@
codeunit 50802 "Upgrade Sample TagGuard Good"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeFeatureX();
end;
local procedure UpgradeFeatureX()
var
UpgradeTag: Codeunit "Upgrade Tag";
begin
if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then
exit;
// Idempotent, retries cleanly after failure, runs exactly once.
UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag());
end;
local procedure FeatureXUpgradeTag(): Code[250]
begin
exit('MS-000002-FeatureX-20260501');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [upgrade-tag, dataversion, version-check, idempotent, guard]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Guard upgrade steps with upgrade tags, not version checks
## Description
`DataVersion()` comparisons tie an upgrade step to a specific release cadence: if the step is skipped or fails on one version and the tenant upgrades past the check before the step succeeds, the step never runs. Upgrade tags, managed by `Codeunit "Upgrade Tag"`, record per-step completion in the tenant database. A tag-guarded step runs once, retries cleanly after failure, and remains idempotent across future versions regardless of the version the customer is upgrading from.
## Best Practice
Guard each step with `if UpgradeTag.HasUpgradeTag(MyTag()) then exit;` at the top of the procedure. After the step completes, call `UpgradeTag.SetUpgradeTag(MyTag())`. Define the tag string in a `Tok`-suffixed Label or returning function so the same constant is referenced at both the guard and the registration (see `register-upgrade-tags-with-getpercompany-getperdatabase-subscribers`).
See sample: `use-upgrade-tags-not-version-checks.good.al`.
## Anti Pattern
`if MyApp.DataVersion().Major < 18 then UpgradeFeatureA();` — the step runs on every upgrade from a pre-18 version, may fail on partial data, and the next retry re-runs work that already succeeded. Nesting version-check branches (`< 14` → step A, `< 17` → step B) compounds the fragility.
See sample: `use-upgrade-tags-not-version-checks.bad.al`.