Regenerate microsoft/knowledge from upstream BCApps instructions

The previous LLM-generated knowledge files contained factual
hallucinations. The most visible was the claim that `FindFirst` /
`FindLast` "forces a full-table scan" on an unfiltered record - it does
not; those APIs return a single row via the current key.

Other inaccuracies the audit found and fixed:

* `FindSet(true)` was described as "taking a LockTable". The correct
  upstream phrasing is that `FindSet(true)` sets
  `ReadIsolation::UpdLock` on the read. UpdLock and LockTable are
  related but distinct mechanisms.
* The list of production-scale tables had been invented beyond the
  upstream source (e.g. "Detailed Cust. Ledg. Entry") without a
  citation. The regenerated list matches the ten tables upstream lists
  with their P95 row counts.
* `SetLoadFields` guidance had been augmented with an extra mechanism
  claim ("the database resolves the filter using the index without
  hydrating the value") not present in upstream.

Approach: full regeneration of `microsoft/knowledge/` from the six
upstream BCApps Code Review instruction files, with Microsoft Learn /
the AL language reference as a secondary source. Every claim in every
regenerated file is anchored to a verbatim upstream quote (or a Learn
URL); the audit trail lives in artifacts/trace-<domain>.json on the
session workspace.

The PR #11 transaction/error-handling cluster is preserved verbatim:

* performance/understand-implicit-transaction-boundary.md
* performance/codeunit-run-as-atomic-sub-operation.{md,good.al,bad.al}
* performance/codeunit-run-requires-prior-commit-inside-transaction.{md,good.al,bad.al}
* performance/use-tryfunction-for-error-catching-not-rollback.{md,good.al,bad.al}
* performance/avoid-commit-inside-loops.{md,good.al,bad.al}
* security/commitbehavior-attribute-scopes-explicit-commits.{md,good.al,bad.al}
* testing/transactionmodel-attribute-governs-test-transactions.{md,good.al,bad.al}

These articles already cite Microsoft Learn and were carefully
cross-referenced; the regeneration skips their topics rather than
duplicating them.

File counts after regeneration:

  performance   35 .md  (5 preserved + 30 new)
  privacy       17 .md
  security      18 .md  (1 preserved + 17 new)
  style         33 .md
  testing        1 .md  (preserved)
  ui            19 .md
  upgrade       18 .md

Total 141 atomic knowledge files, each strictly one rule. All pass
.github/scripts/validate_frontmatter.py with 0 errors and 0 warnings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-05-21 09:53:09 +02:00
parent 613c4b4019
commit a9f3c50863
562 changed files with 6293 additions and 4869 deletions

View file

@ -1,22 +0,0 @@
---
bc-version: [all]
domain: upgrade
keywords: [primary-key, field-type, existing-data, schema, breaking-change]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Assess existing data before primary-key or field-type changes
## Description
Primary-key and field-type changes are upgrade concerns because existing rows may no longer map safely to the new schema. The risk depends on whether the table already has tenant data and whether the old values can be converted without loss. New feature tables with no production rows do not have the same migration burden as ledger, document, or base application tables.
## Best Practice
For existing tables with data, require a concrete migration or compatibility assessment before changing keys or field types. For new tables, new feature tables, or Integer-to-BigInteger changes with evidence that existing values fit, avoid flagging a breaking-change finding without data-impact evidence.
## Anti Pattern
Treating every primary-key edit in a new feature table as a blocker while missing a key or type change on an established ledger-like table. Reviewers need to tie the finding to existing tenant data, not just to the syntactic shape of the schema edit.

View file

@ -0,0 +1,15 @@
// A pre-existing table with millions of rows. Changing the primary key or
// widening a field type without an upgrade plan can fail at deployment.
tableextension 50233 "Cust Ledger Entry Ext" extends "Cust. Ledger Entry"
{
fields
{
// Widening Integer to BigInteger on an existing column with persisted data
// requires an upgrade plan and value-range evidence; not safe as a bare edit.
modify("Entry No.")
{
// (hypothetical: field type change goes here)
}
}
// No accompanying upgrade codeunit, no upgrade tag, no overflow verification.
}

View file

@ -0,0 +1,16 @@
// New feature table introduced in the same change as the keys / field types.
// No existing data, so the layout is free to choose.
table 50232 "New Feature Table"
{
fields
{
field(1; "Entry No."; BigInteger) { }
field(2; "Customer No."; Code[20]) { }
field(3; "Posting Date"; Date) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
key(ByCustomer; "Customer No.", "Posting Date") { }
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [primary-key, field-type, breaking-change, integer-to-biginteger, existing-data]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Primary-key and field-type changes are safe only on tables without existing data
## Description
Primary-key changes and field-type changes (for example widening `Integer` to `BigInteger`) rewrite the on-disk layout of every row in the table. On a new feature table that ships in the same change as the modification, no rows exist and the change is free. On an existing table that already holds tenant data — base-app tables, ledger entries, anything that has been live across releases — the same change can fail outright (key uniqueness violations, value overflow on conversion) or require a full table rewrite during the upgrade window. Either way, the change needs an explicit migration design, not just a metadata edit.
## Best Practice
Treat primary-key and field-type changes as restricted to tables introduced in the same change. For changes on tables with existing data, design and ship the corresponding upgrade procedure (typically backed by `DataTransfer` and an upgrade tag) that guarantees the new layout is achievable for every row, and verify with concrete evidence that the existing values fit the new constraint (no PK collisions, no value-range overflow).
See sample: `breaking-changes-only-on-tables-without-data.good.al`.
## Anti Pattern
Changing the primary key on a base-app table, or widening / narrowing a field type on a table that has been shipping for releases, with no accompanying upgrade plan. The change compiles cleanly and may even deploy on an empty-ish tenant, then fails on customers who actually have data.
See sample: `breaking-changes-only-on-tables-without-data.bad.al`.

View file

@ -1,14 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,26 +0,0 @@
---
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,23 @@
codeunit 50219 "Upgrade Price List Source"
{
Subtype = Upgrade;
local procedure UpdatePriceSourceGroupInPriceListLines()
var
PriceListLine: Record "Price List Line";
begin
// One round-trip per row across a potentially large table.
PriceListLine.SetRange("Source Group", "Price Source Group"::All);
if PriceListLine.FindSet(true) then
repeat
if PriceListLine."Source Type" in
["Price Source Type"::"All Jobs",
"Price Source Type"::Job,
"Price Source Type"::"Job Task"]
then begin
PriceListLine."Source Group" := "Price Source Group"::Job;
PriceListLine.Modify();
end;
until PriceListLine.Next() = 0;
end;
}

View file

@ -0,0 +1,23 @@
codeunit 50218 "Upgrade Price List Source"
{
Subtype = Upgrade;
local procedure UpdatePriceSourceGroupInPriceListLines()
var
PriceListLine: Record "Price List Line";
PriceListLineDataTransfer: DataTransfer;
begin
PriceListLineDataTransfer.SetTables(Database::"Price List Line", Database::"Price List Line");
PriceListLineDataTransfer.AddSourceFilter(
PriceListLine.FieldNo("Source Group"), '=%1', "Price Source Group"::All);
PriceListLineDataTransfer.AddSourceFilter(
PriceListLine.FieldNo("Source Type"), '%1|%2|%3',
"Price Source Type"::"All Jobs",
"Price Source Type"::Job,
"Price Source Type"::"Job Task");
PriceListLineDataTransfer.AddConstantValue(
"Price Source Group"::Job, PriceListLine.FieldNo("Source Group"));
PriceListLineDataTransfer.CopyFields();
Clear(PriceListLineDataTransfer);
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: upgrade
keywords: [datatransfer, large-dataset, bulk-update, modifyall, copyfields, new-field]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use `DataTransfer` for bulk updates on large tables
## Description
Tables that can contain more than 300,000 records, and any newly added field on an existing table, should be initialized with `DataTransfer` rather than a `repeat ... Modify ... until Next() = 0` loop. `DataTransfer` issues a single set-based statement to the database; the loop/modify pattern issues one round-trip per row and accumulates write locks for the duration of the upgrade. On the volumes that drive upgrade pain — ledger entries, item ledger entries, price list lines — the difference is the upgrade running for minutes instead of hours.
## Best Practice
For a bulk update use a `DataTransfer` variable: call `SetTables(Database::"...", Database::"...")` (source and destination may be the same table), add filters with `AddSourceFilter`, set the target value with `AddConstantValue` (or copy a source field with `AddFieldValue`), and execute with `CopyFields()`. To express multiple distinct updates against the same table, `Clear` the `DataTransfer` between executions and configure the next one.
See sample: `datatransfer-for-bulk-init.good.al`.
## Anti Pattern
Iterating with `FindSet(true) ... repeat ... Modify() ... until Next() = 0` to set a single field across an entire large table. On 300k+ rows this is the canonical slow-upgrade footgun.
See sample: `datatransfer-for-bulk-init.bad.al`.
## See also
- `datatransfer-skips-triggers-and-subscribers.md``DataTransfer` does not raise field validation triggers or event subscribers; if a row needs validation logic, `DataTransfer` is the wrong tool.

View file

@ -0,0 +1,16 @@
codeunit 50221 "Upgrade Existing Field"
{
Subtype = Upgrade;
local procedure UpdateCustomerCreditLimit()
var
Customer: Record Customer;
DT: DataTransfer;
begin
// "Credit Limit (LCY)" has OnValidate logic that recalculates risk fields
// and notifies subscribers. DataTransfer skips both derived data drifts.
DT.SetTables(Database::Customer, Database::Customer);
DT.AddConstantValue(50000, Customer.FieldNo("Credit Limit (LCY)"));
DT.CopyFields();
end;
}

View file

@ -0,0 +1,16 @@
codeunit 50220 "Upgrade New Field Init"
{
Subtype = Upgrade;
local procedure InitializeNewFlagOnMyTable()
var
MyTable: Record "My Table";
DT: DataTransfer;
begin
// "New Flag" is added in the same change as this upgrade procedure.
// No existing validation logic depends on it, so DataTransfer is safe.
DT.SetTables(Database::"My Table", Database::"My Table");
DT.AddConstantValue(true, MyTable.FieldNo("New Flag"));
DT.CopyFields();
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: upgrade
keywords: [datatransfer, validate-trigger, event-subscriber, side-effects, business-logic]
technologies: [al]
countries: [w1]
application-area: [all]
---
# `DataTransfer` does not fire validation triggers or event subscribers
## Description
`DataTransfer` writes directly at the database layer. It does not invoke field `OnValidate` triggers, table `OnModify` triggers, or any `OnAfterModifyEvent` / `OnBeforeValidate...` event subscribers that a normal `Record.Modify(true)` would. This is precisely what makes it fast — and precisely what makes it a footgun when the field being updated has validation logic that other code relies on. The receiving code never gets the signal that a row changed, derived fields stay stale, audit hooks do not run.
For *new fields and tables added in the same change* this is fine: nothing yet depends on the validation. For *pre-existing fields with validation logic*, `DataTransfer` quietly bypasses business logic that may be load-bearing for posting, calculation, or integration scenarios.
## Best Practice
Use `DataTransfer` only when the field or table is new in the same change — initial population is the canonical safe case. When updating a pre-existing field that has validation logic, either use `Modify(true)` to honour the triggers, or, if `DataTransfer` is still required for performance reasons, leave a comment that explicitly states "validation triggers and event subscribers are intentionally not raised" and verify with the field's owner that this is safe.
See sample: `datatransfer-skips-triggers-and-subscribers.good.al`.
## Anti Pattern
Reaching for `DataTransfer` to update an existing field with non-trivial `OnValidate` logic, without a comment and without confirming that subscribers can be skipped. The upgrade succeeds; runtime behaviour drifts silently.
See sample: `datatransfer-skips-triggers-and-subscribers.bad.al`.

View file

@ -1,15 +0,0 @@
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

@ -1,26 +0,0 @@
---
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,17 @@
codeunit 50207 "Upgrade Graceful"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeCustomerLink('C00010');
end;
local procedure UpgradeCustomerLink(CustomerNo: Code[20])
var
Customer: Record Customer;
begin
// Throws if the record is missing aborts the upgrade.
Customer.Get(CustomerNo);
end;
}

View file

@ -0,0 +1,26 @@
codeunit 50206 "Upgrade Graceful"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeCustomerLink('C00010');
end;
local procedure UpgradeCustomerLink(CustomerNo: Code[20])
var
Customer: Record Customer;
begin
if not Customer.Get(CustomerNo) then begin
Session.LogMessage(
'0000ABC',
'Customer not found during upgrade',
Verbosity::Warning,
DataClassification::SystemMetadata,
TelemetryScope::ExtensionPublisher,
'CustomerNo', CustomerNo);
exit;
end;
// Continue upgrade work using Customer ...
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [error-handling, telemetry, session-logmessage, blocking, graceful]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Log telemetry; do not raise errors that block the upgrade
## Description
When upgrade code encounters unexpected data — a record it expected to find, a relationship it assumed to be intact — the response is to log telemetry and continue, not to raise an error. A runtime error inside an upgrade codeunit aborts the upgrade for the company or database, leaving the customer stuck on the old version. Customers should not be blocked from upgrading because of a data inconsistency that an upgrade routine could not have anticipated.
## Best Practice
When an upgrade procedure detects something missing, call `Session.LogMessage` with a stable event ID, classify the message verbosity (typically `Warning`), and `exit` the procedure so the rest of the upgrade can proceed. The platform telemetry then surfaces the situation to the partner without breaking the customer.
See sample: `do-not-block-upgrade-on-data-errors.good.al`.
## Anti Pattern
Calling `Record.Get(Key)` (or any other erroring API) and letting the error propagate out of the upgrade trigger. The first tenant with imperfect data fails to upgrade, and the failure surfaces as a hard upgrade error rather than as a telemetry signal.
See sample: `do-not-block-upgrade-on-data-errors.bad.al`.

View file

@ -1,22 +0,0 @@
---
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. Do not apply this rule to ordinary runtime codeunits, pages, tables, install procedures, or background jobs unless they are directly invoked from an upgrade trigger.
## 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

@ -1,23 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,33 +0,0 @@
---
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. Renaming the caption on an existing ordinal is fine.
When a value must be retired, follow the two-stage obsoletion workflow:
1. **First release:** Mark the value with `ObsoleteState = Pending`, `ObsoleteReason`, and `ObsoleteTag`. This gives callers at least one release cycle to migrate.
2. **Later release:** Advance to `ObsoleteState = Removed` once all callers have been updated.
Never skip straight to `ObsoleteState = Removed` without first going through `Pending` — doing so removes the warning cycle that callers depend on. Do not reclaim the ordinal in either stage. See also: `use-obsolete-pending-before-removed.md`.
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,11 @@
enum 50226 "My Enum"
{
Extensible = true;
value(0; "First") { }
value(1; "NewMiddleValue") { } // Inserted in the middle shifts ordinals.
value(2; "Second") { }
value(3; "Third") { }
// Or: a previously declared value(1; "Second") removed without obsoletion
// any persisted "1" now maps to whatever currently occupies ordinal 1.
}

View file

@ -0,0 +1,9 @@
enum 50225 "My Enum"
{
Extensible = true;
value(0; "First") { }
value(1; "Second") { }
value(2; "Third") { }
value(3; "NewValue") { } // Appended at the end no existing ordinal shifts.
}

View file

@ -0,0 +1,31 @@
---
bc-version: [all]
domain: upgrade
keywords: [enum, ordinal, additive, append, backward-compatible, breaking-change]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Add new enum values only at the end
## Description
An AL `enum` is a fixed list of ordinal-named values. Persisted rows reference enum members by ordinal, not by name. The only enum mutation that preserves the meaning of every existing row is **appending a new value at the end** — every previously valid ordinal still maps to the same member. Inserting a new value in the middle, renumbering existing values, or removing a value without obsoletion all shift ordinals: rows written with the old layout silently take on the new member at their saved ordinal.
## Best Practice
When adding an enum value, place it after the last existing `value(N; ...)` entry, with an ordinal strictly greater than every existing one. Never renumber existing entries. To retire a value, do not delete it: mark it `ObsoleteState = Pending` (and later `Removed`) with `ObsoleteReason` and `ObsoleteTag` so the ordinal remains taken.
See sample: `enum-values-additive-at-end.good.al`.
## Anti Pattern
Inserting a value between existing entries ("just put `NewMiddleValue` between `First` and `Second`"), or removing a value from the enum without first going through `ObsoleteState = Pending``Removed`. Every row whose persisted ordinal matched the removed or shifted value now reads as a different member.
See sample: `enum-values-additive-at-end.bad.al`.
## See also
- `obsoletion-requires-reason-and-tag.md` — how to retire an enum member correctly.
- `obsolete-pending-to-removed-staging.md` — the `Pending → Removed` lifecycle.

View file

@ -1,22 +0,0 @@
---
bc-version: [all]
domain: upgrade
keywords: [hybrid, migration, upgrade-tag, false-positive, datamigration]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Exclude Hybrid migration codeunits from standard upgrade rules
## Description
Hybrid migration codeunits such as `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` are one-time migration paths with established migration-specific patterns. They are not ordinary `Subtype = Upgrade` steps, and forcing standard upgrade-tag, trigger-shape, or missing-upgrade-code rules onto them creates false positives.
## Best Practice
When a change is clearly in a Hybrid migration codeunit or migration namespace, review it against migration-specific data handling and destination classification rules. Do not flag it merely because it lacks ordinary upgrade tags or because its control flow differs from standard upgrade codeunits.
## Anti Pattern
Reporting "missing upgrade tag" or "missing standard upgrade code" on a `HybridSL`, `HybridGP`, `HybridBC`, or `HybridBaseDeployment` codeunit solely because it does not look like a normal upgrade step. The name and migration context are the signal that different rules apply.

View file

@ -0,0 +1,13 @@
codeunit 50211 "Install My Extension"
{
Subtype = Install;
trigger OnInstallAppPerCompany()
begin
// No DataVersion() guard this runs on every reinstall and upgrade
// path, duplicating seed rows.
SeedDefaultRows();
end;
local procedure SeedDefaultRows() begin end;
}

View file

@ -1,4 +1,4 @@
codeunit 50821 "Upgrade Sample FirstInstall Good"
codeunit 50210 "Install My Extension"
{
Subtype = Install;
@ -10,11 +10,6 @@ codeunit 50821 "Upgrade Sample FirstInstall Good"
if AppInfo.DataVersion() <> Version.Create('0.0.0.0') then
exit;
// First-install-only initialization follows here.
InsertDefaultSetup();
end;
local procedure InsertDefaultSetup()
begin
// Install-only seed code goes here.
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: upgrade
keywords: [dataversion, first-install, on-install-app-per-company, moduleinfo, zero-version]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Detect first install with `DataVersion() = Version.Create('0.0.0.0')`
## Description
On the first install of an extension on a tenant the platform records a zero data version: `AppInfo.DataVersion()` returns `Version.Create('0.0.0.0')`. Subsequent upgrades record the actual previous version. The `OnInstallAppPerCompany` trigger uses this distinction to detect a brand-new install — for example, to seed default rows that should not be re-inserted on a normal upgrade. This is the one place where reading `DataVersion()` is the right tool; for everything else, use an upgrade tag.
## Best Practice
In `OnInstallAppPerCompany`, fetch the current `ModuleInfo` via `NavApp.GetCurrentModuleInfo`, compare `AppInfo.DataVersion()` to `Version.Create('0.0.0.0')`, and run install-only seed logic only when they match. On any non-zero data version, exit immediately — that path is an upgrade, not an install.
See sample: `first-install-dataversion-zero-check.good.al`.
## Anti Pattern
Treating `OnInstallAppPerCompany` as if it always implies "fresh tenant". The trigger also fires when reinstalling over an existing data set; without the `0.0.0.0` guard, install-only seed code re-runs on every upgrade and duplicates rows.
See sample: `first-install-dataversion-zero-check.bad.al`.
## See also
- `use-upgrade-tags-not-version-checks.md` — for upgrade steps after first install, use upgrade tags rather than `DataVersion`.

View file

@ -0,0 +1,20 @@
codeunit 50205 "Upgrade Guarded Reads"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeMyFeature();
end;
local procedure UpgradeMyFeature()
var
Item: Record Item;
Customer: Record Customer;
Vendor: Record Vendor;
begin
Item.Get('1000'); // Throws if missing; aborts upgrade.
Customer.FindSet(); // Throws if empty.
Vendor.FindLast(); // Throws if empty.
end;
}

View file

@ -0,0 +1,22 @@
codeunit 50204 "Upgrade Guarded Reads"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeMyFeature();
end;
local procedure UpgradeMyFeature()
var
Item: Record Item;
Customer: Record Customer;
Vendor: Record Vendor;
begin
if Item.Get('1000') then
Item.Modify();
if Customer.FindSet() then;
if not Vendor.FindLast() then
exit;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [get, findset, findlast, guard, if-then, runtime-error]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Guard every database read in upgrade code with `if`
## Description
Inside an upgrade codeunit (or any procedure transitively invoked from `OnUpgradePerCompany` / `OnUpgradePerDatabase`), an unguarded `Record.Get`, `Record.FindSet`, or `Record.FindLast` raises a runtime error when the row or set is missing. In upgrade context that error aborts the entire upgrade for the company or database — a far worse outcome than the missing data itself. Records the upgrade reasons about may legitimately not exist on every customer's tenant.
## Best Practice
Wrap every read in an `if`. `if Item.Get(No) then ...`, `if Customer.FindSet() then;`, `if not Vendor.FindLast() then exit;`. The empty-then form `if Customer.FindSet() then;` is the idiomatic way to attempt a read whose only purpose is to position a record, while swallowing the "not found" case.
See sample: `guard-database-reads.good.al`.
## Anti Pattern
Calling `Item.Get()`, `Customer.FindSet()`, or `Vendor.FindLast()` bare in upgrade code. The first tenant whose data does not match the upgrade's assumptions will fail to upgrade.
See sample: `guard-database-reads.bad.al`.

View file

@ -1,19 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,26 +0,0 @@
---
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

@ -1,13 +0,0 @@
codeunit 50831 "Upgrade Sample Trigger Bad"
{
Subtype = Upgrade;
trigger OnValidateUpgradePerCompany()
begin
ValidateAllCustomers();
end;
local procedure ValidateAllCustomers()
begin
end;
}

View file

@ -1,25 +0,0 @@
codeunit 50830 "Upgrade Sample Trigger Good"
{
Subtype = Upgrade;
trigger OnValidateUpgradePerCompany()
var
UpgradeTag: Codeunit "Upgrade Tag";
begin
// Required for regulatory data validation before this release can run.
if UpgradeTag.HasUpgradeTag(ValidationTag()) then
exit;
ValidateAllCustomers();
UpgradeTag.SetUpgradeTag(ValidationTag());
end;
local procedure ValidateAllCustomers()
begin
end;
local procedure ValidationTag(): Code[250]
begin
exit('MS-000010-ValidateCustomers-20260501');
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [all]
domain: upgrade
keywords: [onvalidateupgrade, trigger, upgrade-tag, performance, justification]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Guard performance-impacting upgrade triggers
## Description
Upgrade validation triggers such as `OnValidateUpgradePerCompany` can run during upgrade for every tenant and company. Expensive validation, full-table scans, or repair logic in those triggers becomes part of the upgrade's critical path. The trigger is acceptable only when the work is necessary and when re-execution is prevented.
## Best Practice
Add written justification for the trigger's work and guard it with an upgrade tag just like a data-migration step. Check `HasUpgradeTag` before the expensive work and call `SetUpgradeTag` only after the work succeeds, so retries do not re-run completed validation.
See sample: `guard-performance-impacting-upgrade-triggers.good.al`.
## Anti Pattern
Putting `ValidateAllCustomers()`, table scans, or external-style setup validation directly in `OnValidateUpgradePerCompany` without a skip tag. The work runs on every upgrade attempt, including retries after unrelated failures.
See sample: `guard-performance-impacting-upgrade-triggers.bad.al`.

View file

@ -0,0 +1,24 @@
---
bc-version: [all]
domain: upgrade
keywords: [hybrid-migration, hybrid-bc14, hybrid-sl, hybrid-gp, hybrid-base-deployment, one-time-migration]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Hybrid migration codeunits are not standard upgrade codeunits
## Description
Codeunits like `HybridBC14`, `HybridSL`, `HybridGP`, and `HybridBaseDeployment` implement one-time migration paths from a specific source system into Business Central. They run in a different pipeline from the standard per-company / per-database upgrade triggers and follow patterns shaped by that source — staging tables, schema-mapped imports, and per-source post-processing. The rules that apply to standard upgrade codeunits — guarded reads, no external calls, `DataTransfer` for bulk init, `Subtype = Upgrade`, upgrade tags — are not the right yardstick for these migration codeunits.
## Best Practice
Treat a hybrid migration codeunit as a domain of its own. If you need to add or modify migration logic, follow the conventions of the surrounding hybrid migration codebase (which has its own dispatcher, its own way of recording progress, and its own error handling) rather than imposing standard upgrade conventions on it. Conversely, do not borrow hybrid-migration patterns into standard upgrade codeunits — the platform contract is different.
When reviewing changes inside a hybrid migration codeunit, do not flag missing upgrade tags, missing `Subtype = Upgrade`, or missing `OnUpgradePerCompany` wiring. None of those apply.
## Anti Pattern
Reviewing a change inside `HybridBC14` / `HybridSL` / `HybridGP` / `HybridBaseDeployment` against standard upgrade rules and flagging the absence of `Subtype = Upgrade` or upgrade-tag plumbing.

View file

@ -1,14 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,26 +0,0 @@
---
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 without InitValue where `false` is the intended existing-row value; new extensions, new feature tables, or setup tables with no meaningful existing data to migrate; 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,15 @@
tableextension 50224 "MyTable Ext" extends "My Table"
{
fields
{
// InitValue only applies to rows inserted after deployment.
// Pre-existing rows silently carry the datatype default (false).
field(50200; "New Flag"; Boolean)
{
DataClassification = CustomerContent;
Caption = 'New Flag';
InitValue = true;
}
}
// No accompanying upgrade codeunit to back-fill existing rows.
}

View file

@ -0,0 +1,43 @@
tableextension 50222 "MyTable Ext" extends "My Table"
{
fields
{
field(50200; "New Flag"; Boolean)
{
DataClassification = CustomerContent;
Caption = 'New Flag';
InitValue = true;
}
}
}
codeunit 50223 "Upgrade MyTable NewFlag"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeMyTableNewFlag();
end;
local procedure UpgradeMyTableNewFlag()
var
MyTable: Record "My Table";
UpgradeTag: Codeunit "Upgrade Tag";
DT: DataTransfer;
begin
if UpgradeTag.HasUpgradeTag(MyTableNewFlagTag()) then
exit;
DT.SetTables(Database::"My Table", Database::"My Table");
DT.AddConstantValue(true, MyTable.FieldNo("New Flag"));
DT.CopyFields();
UpgradeTag.SetUpgradeTag(MyTableNewFlagTag());
end;
local procedure MyTableNewFlagTag(): Code[250]
begin
exit('MS-123456-MyTable-NewFlag-20240101');
end;
}

View file

@ -0,0 +1,32 @@
---
bc-version: [all]
domain: upgrade
keywords: [initvalue, new-field, existing-rows, default-value, table-extension]
technologies: [al]
countries: [w1]
application-area: [all]
---
# `InitValue` does not back-fill existing rows
## Description
`InitValue` on a field defines the value the platform assigns when a *new* record is inserted. It does not touch rows that already exist when the field is added. When a new field is added to an existing table — directly or via a table extension — every pre-existing row receives the datatype default (`false` for Boolean, `0` for numeric, empty for text), not the `InitValue`. If the intended semantics require existing rows to carry the `InitValue`, the change is incomplete without an upgrade routine that sets the field on those rows.
Several legitimate cases do NOT need upgrade code:
- New fields on brand-new tables (no existing rows).
- New `Boolean` fields without `InitValue` where the datatype default `false` is the intended value.
- New fields on configuration / setup tables that have no meaningful "existing data".
- Informational or optional fields (logging, preferences, tracking) where `false` / empty is a valid state.
## Best Practice
When a new field on an existing table has an `InitValue` that matters, ship an upgrade procedure that walks the existing rows and sets the field to the same value — typically via `DataTransfer.AddConstantValue` for performance — guarded by an upgrade tag.
See sample: `initvalue-does-not-update-existing-rows.good.al`.
## Anti Pattern
Adding a field with `InitValue = true;` (or any non-default `InitValue`) and shipping no upgrade code. Existing rows silently carry the datatype default, leaving the table in two states: rows created before the upgrade with the wrong value, and rows created after with the right one.
See sample: `initvalue-does-not-update-existing-rows.bad.al`.

View file

@ -0,0 +1,13 @@
codeunit 50235 "Upgrade With Validation"
{
Subtype = Upgrade;
trigger OnValidateUpgradePerCompany()
begin
// No skip logic and no written justification full-table validation
// runs on every single upgrade pass.
ValidateAllCustomers();
end;
local procedure ValidateAllCustomers() begin end;
}

View file

@ -0,0 +1,25 @@
codeunit 50234 "Upgrade With Validation"
{
Subtype = Upgrade;
trigger OnValidateUpgradePerCompany()
var
UpgradeTag: Codeunit "Upgrade Tag";
begin
// Justification: regulatory compliance requires a full-table scan once
// per tenant after this release. Tag prevents re-runs.
if UpgradeTag.HasUpgradeTag(MyValidationUpgradeTag()) then
exit;
ValidateAllCustomers();
UpgradeTag.SetUpgradeTag(MyValidationUpgradeTag());
end;
local procedure ValidateAllCustomers() begin end;
local procedure MyValidationUpgradeTag(): Code[250]
begin
exit('MS-123456-CustomerValidation-20240101');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [on-validate-upgrade-per-company, performance-impact, skip-logic, justification, upgrade-tag]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Performance-impacting upgrade triggers need justification and skip logic
## Description
Triggers such as `OnValidateUpgradePerCompany` run on every upgrade pass. When their body performs non-trivial work — full-table scans, cross-table validations — the cost is paid on every upgrade of every tenant, even when there is nothing to validate. That cost is acceptable only when the validation is critical (regulatory compliance, data-integrity guarantees the platform depends on) AND the trigger short-circuits once it has done its work.
## Best Practice
A performance-impacting upgrade trigger carries two things: a written comment that names the reason the work has to happen on every upgrade pass, and an early-exit guard backed by an upgrade tag so the work runs at most once per tenant. The `HasUpgradeTag` check at the top exits when the validation has already been recorded; the `SetUpgradeTag` call at the bottom records completion.
See sample: `minimize-onvalidate-upgrade-triggers.good.al`.
## Anti Pattern
Doing real work in `OnValidateUpgradePerCompany` with no upgrade-tag guard. The same scan runs every upgrade, multiplying upgrade time by the number of releases the customer takes.
See sample: `minimize-onvalidate-upgrade-triggers.bad.al`.

View file

@ -0,0 +1,13 @@
codeunit 50215 "Upgrade No External"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
var
Client: HttpClient;
Response: HttpResponseMessage;
begin
// External call inside upgrade code can hang or fail and abort the upgrade.
Client.Get('https://external-service.contoso.com/api/sync', Response);
end;
}

View file

@ -0,0 +1,17 @@
codeunit 50214 "Upgrade No External"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
var
ExternalSyncSetup: Record "External Sync Setup";
begin
// Defer the external call: just set a flag the runtime path will pick up.
if not ExternalSyncSetup.Get() then begin
ExternalSyncSetup.Init();
ExternalSyncSetup.Insert();
end;
ExternalSyncSetup."Resync Required" := true;
ExternalSyncSetup.Modify();
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: upgrade
keywords: [httpclient, dotnet, external-service, network-call, blocking, upgrade-rollback]
technologies: [al]
countries: [w1]
application-area: [all]
---
# No external calls inside upgrade codeunits
## Description
Upgrade code runs in a constrained execution window: the tenant is mid-upgrade, no users are signed in, and a failure aborts the entire transaction. An external HTTP call, DotNet interop call, or any other I/O to a system outside Business Central can hang or fail for reasons completely unrelated to the upgrade — DNS, expired credentials, a service that is itself being upgraded — and the upgrade fails with it. Rolling back from such a failure is hard because the upgrade pipeline assumes its work is deterministic.
The rule applies inside any codeunit with `Subtype = Upgrade` and to any procedure transitively invoked from `OnUpgrade...` triggers. The same calls in regular runtime code — pages, table triggers, normal codeunits, background jobs — are fine.
## Best Practice
Defer external calls to runtime code. If a piece of upgrade work conceptually needs data from an external service, set a flag or write a queue row during upgrade and have the runtime code make the call later (for example on first user sign-in or via job queue), where retries and degraded modes are tractable.
See sample: `no-external-calls-in-upgrade.good.al`.
## Anti Pattern
Calling `HttpClient.Get`, `HttpClient.Post`, or DotNet interop methods from `OnUpgradePerCompany`, `OnUpgradePerDatabase`, or any procedure they invoke.
See sample: `no-external-calls-in-upgrade.bad.al`.

View file

@ -0,0 +1,14 @@
// Skipping the Pending stage and going straight to Removed leaves callers
// and persisted rows with no migration window.
enum 50231 "My Enum"
{
Extensible = true;
value(0; "First") { }
value(1; "Second")
{
ObsoleteState = Removed;
ObsoleteReason = 'Replaced by NewValue';
ObsoleteTag = '22.0';
}
value(2; "Third") { }
}

View file

@ -0,0 +1,29 @@
// Release N: deprecation announced.
enum 50229 "My Enum N"
{
Extensible = true;
value(0; "First") { }
value(1; "Second")
{
ObsoleteState = Pending;
ObsoleteReason = 'Replaced by NewValue';
ObsoleteTag = '22.0';
}
value(2; "Third") { }
value(3; "NewValue") { }
}
// Release N+1 (or later): removal staged; upgrade code now migrates persisted rows.
enum 50230 "My Enum NPlus1"
{
Extensible = true;
value(0; "First") { }
value(1; "Second")
{
ObsoleteState = Removed;
ObsoleteReason = 'Replaced by NewValue';
ObsoleteTag = '22.0';
}
value(2; "Third") { }
value(3; "NewValue") { }
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [obsolete-state, pending, removed, lifecycle, clean-flag, upgrade-code-timing]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Stage obsoletion `Pending → Removed`; write upgrade code on removal
## Description
`ObsoleteState` has a deliberate two-step lifecycle. `Pending` keeps the element compilable and present — callers still find it but receive a deprecation warning. `Removed` marks the element as gone from the contract; the body may be empty or wrapped in `#if not CLEAN<version>` so the symbol survives only for binary compatibility. Upgrade code that migrates persisted data away from the obsolete element is normally written when the element moves to `Removed`, not when it goes `Pending`. `ObsoleteState = Pending` without accompanying upgrade code is the expected steady state during the deprecation window; reviewers should not flag that combination as missing migration.
## Best Practice
Stage the deprecation across releases. Step 1: mark `Pending` with reason and tag; consumers are warned but data and code keep working. Step 2: in a later release, transition to `Removed` and (if persisted data references the element) ship an upgrade procedure that migrates that data — gated by an upgrade tag. The standard mechanic for retiring the actual implementation body is to remove the `#if not CLEAN<version>` block in the same release that flips the state to `Removed`.
See sample: `obsolete-pending-to-removed-staging.good.al`.
## Anti Pattern
Jumping straight to `ObsoleteState = Removed` without a prior `Pending` release. Consumers have no deprecation window to migrate and any data still referencing the element is stranded. Equally wrong: leaving an element `Pending` indefinitely and never staging its removal — the deprecation never completes.
See sample: `obsolete-pending-to-removed-staging.bad.al`.

View file

@ -0,0 +1,8 @@
codeunit 50228 "Old Method Holder"
{
// ObsoleteState set without ObsoleteReason or ObsoleteTag.
[Obsolete('')]
procedure OldMethod()
begin
end;
}

View file

@ -0,0 +1,12 @@
codeunit 50227 "Old Method Holder"
{
[Obsolete('Use NewMethod instead for better performance', '22.0')]
procedure OldMethod()
begin
// Body kept while ObsoleteState = Pending; warns at call sites.
end;
procedure NewMethod()
begin
end;
}

View file

@ -0,0 +1,36 @@
---
bc-version: [all]
domain: upgrade
keywords: [obsolete-state, obsolete-reason, obsolete-tag, deprecation, metadata]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Mark obsolete elements with `ObsoleteState`, `ObsoleteReason`, and `ObsoleteTag`
## Description
When a procedure, field, table, page, or enum value is being retired, AL requires three pieces of metadata to declare the deprecation:
- `ObsoleteState``Pending` while the element still exists but is being phased out, `Removed` once it should no longer be used.
- `ObsoleteReason` — a short human-readable string explaining what to use instead. Tooling and downstream consumers surface this when warning callers.
- `ObsoleteTag` — a stable version-like marker (typically the release version in which the deprecation was introduced, e.g. `'22.0'`).
Omitting `ObsoleteReason` or `ObsoleteTag` leaves consumers with `ObsoleteState = Pending` but no guidance and no traceability. Declaring `ObsoleteState = Removed` without a reason or tag is the same failure with a stronger blast radius.
## Best Practice
Every obsoleted element carries all three properties together. The reason names the replacement explicitly; the tag is the version in which the deprecation was introduced and stays stable for the life of the deprecation.
See sample: `obsoletion-requires-reason-and-tag.good.al`.
## Anti Pattern
Setting only `ObsoleteState = Pending;` (or `Removed`) without `ObsoleteReason` and `ObsoleteTag`. Callers see a warning with no explanation, and the deprecation cannot be tracked by version.
See sample: `obsoletion-requires-reason-and-tag.bad.al`.
## See also
- `obsolete-pending-to-removed-staging.md` — when to advance `Pending` to `Removed` and write upgrade code.

View file

@ -1,4 +1,4 @@
codeunit 50805 "Upgrade Sample TagRegister Bad"
codeunit 50213 "Upgrade Tag Registration"
{
Subtype = Upgrade;
@ -6,17 +6,15 @@ codeunit 50805 "Upgrade Sample TagRegister Bad"
var
UpgradeTag: Codeunit "Upgrade Tag";
begin
if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then
if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then
exit;
UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag());
UpgradeTag.SetUpgradeTag(MyUpgradeTag());
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]
local procedure MyUpgradeTag(): Code[250]
begin
exit('MS-000004-FeatureX-20260501');
exit('MS-123456-MyFeature-20240101');
end;
// No OnGetPerCompanyUpgradeTags subscriber the tag is unknown to the platform.
}

View file

@ -1,4 +1,4 @@
codeunit 50804 "Upgrade Sample TagRegister Good"
codeunit 50212 "Upgrade Tag Registration"
{
Subtype = Upgrade;
@ -6,20 +6,20 @@ codeunit 50804 "Upgrade Sample TagRegister Good"
var
UpgradeTag: Codeunit "Upgrade Tag";
begin
if UpgradeTag.HasUpgradeTag(FeatureXUpgradeTag()) then
if UpgradeTag.HasUpgradeTag(MyUpgradeTag()) then
exit;
// Upgrade work ...
UpgradeTag.SetUpgradeTag(MyUpgradeTag());
end;
UpgradeTag.SetUpgradeTag(FeatureXUpgradeTag());
local procedure MyUpgradeTag(): Code[250]
begin
exit('MS-123456-MyFeature-20240101');
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');
PerCompanyUpgradeTags.Add(MyUpgradeTag());
end;
}

View file

@ -1,26 +1,28 @@
---
bc-version: [all]
domain: upgrade
keywords: [upgrade-tag, ongetpercompanyupgradetags, ongetperdatabaseupgradetags, registration]
keywords: [upgrade-tag, event-subscriber, on-get-per-company-upgrade-tags, on-get-per-database-upgrade-tags, registration]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Register every upgrade tag with the matching PerCompany or PerDatabase subscriber
# Register every upgrade tag with the platform via an event 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.
The `Upgrade Tag` codeunit only recognizes a tag if the tag was published to the platform through one of two events on that codeunit: `OnGetPerCompanyUpgradeTags` for tags set inside `OnUpgradePerCompany`, and `OnGetPerDatabaseUpgradeTags` for tags set inside `OnUpgradePerDatabase`. A tag that is `Set` and `Has`-checked in code but never added to one of these lists is unknown to the platform — its semantics around skip-on-reinstall, telemetry, and operator queries do not apply.
The registration scope must match where the tag is set: a tag used from `OnUpgradePerCompany` registers in `OnGetPerCompanyUpgradeTags`; a tag used from `OnUpgradePerDatabase` registers in `OnGetPerDatabaseUpgradeTags`. Crossing the scopes silently breaks the tag.
## 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`. Treat this mapping as a review point, not just a naming convention. Keep the tag string in a single source (Label or function) and reference it at the guard, the setter, and the registration.
For every new upgrade tag, add one line to the matching subscriber: `PerCompanyUpgradeTags.Add(MyUpgradeTag());` or `PerDatabaseUpgradeTags.Add(MyUpgradeTag());`. Place the subscribers in the same codeunit (or a dedicated "Upgrade Tag Definitions" codeunit) so the tag string and its registration stay together.
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, or registering a tag used from `OnUpgradePerCompany` in `OnGetPerDatabaseUpgradeTags`. The code compiles and the step completes, but the tag is invisible or registered at the wrong scope.
Calling `UpgradeTag.SetUpgradeTag(MyUpgradeTag())` without ever adding `MyUpgradeTag()` to the corresponding `OnGetPerCompany...` / `OnGetPerDatabase...` subscriber.
See sample: `register-upgrade-tags-with-subscribers.bad.al`.

View file

@ -1,14 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,26 +0,0 @@
---
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,12 @@
codeunit 50217 "Report Selection Seeder"
{
procedure AddReportSelectionEntries()
var
ReportSelections: Record "Report Selections";
begin
// No context check fires during upgrade and silently inserts rows
// the upgrade pipeline never asked for.
ReportSelections.Init();
ReportSelections.Insert();
end;
}

View file

@ -0,0 +1,15 @@
codeunit 50216 "Report Selection Seeder"
{
procedure AddReportSelectionEntries()
var
ReportSelections: Record "Report Selections";
begin
// Do not add report-selection entries during upgrade; the upgrade pipeline
// does not need them and re-running this on every upgrade is wasteful.
if GetExecutionContext() = ExecutionContext::Upgrade then
exit;
ReportSelections.Init();
ReportSelections.Insert();
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: upgrade
keywords: [get-execution-context, execution-context-upgrade, skip, report-selection, runtime-trigger]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Skip non-essential runtime work when `GetExecutionContext() = ExecutionContext::Upgrade`
## Description
Runtime procedures (table triggers, install routines, helpers called from many places) sometimes fire during the upgrade window because the upgrade itself touches the data they react to. When the work those procedures do is not strictly required for the upgrade to succeed — inserting report-selection entries, seeding optional configuration, sending welcome notifications — they should detect upgrade context with `GetExecutionContext() = ExecutionContext::Upgrade` and exit. This keeps upgrade transactions tight and avoids side effects that the upgrade pipeline did not ask for.
This is the opposite of a load-bearing concern: code that MUST run during the upgrade does not consult execution context. The check is for *optional* side effects that happen to be wired into runtime code paths.
## Best Practice
In a runtime procedure that performs non-essential side effects, guard the side-effect block with `if GetExecutionContext() = ExecutionContext::Upgrade then exit;` and include a brief comment explaining what is being skipped and why.
See sample: `skip-nonessential-work-via-execution-context.good.al`.
## Anti Pattern
Using `GetExecutionContext()` to *enable* upgrade behaviour from outside an upgrade codeunit. Upgrade behaviour belongs in a codeunit with `Subtype = Upgrade`; runtime code should only use the check to *suppress* optional work.
See sample: `skip-nonessential-work-via-execution-context.bad.al`.

View file

@ -0,0 +1,12 @@
codeunit 50203 "Upgrade Orchestrator"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
var
Customer: Record Customer;
begin
// Direct implementation in the trigger body wrong.
Customer.ModifyAll("Some Field", true);
end;
}

View file

@ -0,0 +1,19 @@
codeunit 50202 "Upgrade Orchestrator"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeMyFeature();
UpgradeSecondFeature();
end;
local procedure UpgradeMyFeature()
var
Customer: Record Customer;
begin
Customer.ModifyAll("Some Field", true);
end;
local procedure UpgradeSecondFeature() begin end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: upgrade
keywords: [on-upgrade-per-company, on-upgrade-per-database, trigger-body, helper-procedure, structure]
technologies: [al]
countries: [w1]
application-area: [all]
---
# `OnUpgradePerCompany` / `OnUpgradePerDatabase` should call helpers, not inline logic
## Description
The `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on an upgrade codeunit are dispatch points, not implementation slots. They should contain only calls to named local procedures — one call per feature being upgraded. Putting `ModifyAll`, record loops, or any business logic directly inside the trigger body makes the upgrade impossible to read, impossible to selectively skip via upgrade tags per feature, and impossible to extend without touching the trigger itself.
Empty `OnUpgradePerCompany` / `OnUpgradePerDatabase` triggers are acceptable — they may be placeholders for future use or artifacts from cleanup.
## Best Practice
Each upgrade trigger contains an ordered list of procedure calls, one per feature: `UpgradeFeatureA();` `UpgradeFeatureB();`. Each procedure handles its own upgrade tag, its own data work, and can be added or removed independently.
See sample: `triggers-call-helpers-not-implementations.good.al`.
## Anti Pattern
Implementing record loops, `ModifyAll`, or other data work directly in the trigger body. The trigger then mixes orchestration with implementation, and adding a second feature requires editing the trigger rather than appending one line.
See sample: `triggers-call-helpers-not-implementations.bad.al`.

View file

@ -0,0 +1,10 @@
codeunit 50201 "Upgrade My Feature"
{
// Missing Subtype = Upgrade; the OnUpgrade trigger is never dispatched.
trigger OnUpgradePerCompany()
begin
UpgradeMyFeature();
end;
local procedure UpgradeMyFeature() begin end;
}

View file

@ -0,0 +1,17 @@
codeunit 50200 "Upgrade My Feature"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
UpgradeMyFeature();
end;
trigger OnUpgradePerDatabase()
begin
UpgradeMyGlobalSetup();
end;
local procedure UpgradeMyFeature() begin end;
local procedure UpgradeMyGlobalSetup() begin end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [upgrade-codeunit, subtype, on-upgrade-per-company, on-upgrade-per-database, trigger]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Upgrade logic must live in a codeunit with `Subtype = Upgrade`
## Description
A codeunit only participates in the upgrade pipeline when it sets `Subtype = Upgrade`. The platform then dispatches the `OnUpgradePerCompany` and `OnUpgradePerDatabase` triggers on that codeunit during upgrade. A codeunit without `Subtype = Upgrade` — even one that declares an `OnUpgradePerCompany` trigger — is not an upgrade codeunit, and reviewers ignore it for upgrade concerns. Conversely, any procedure invoked transitively from an `OnUpgrade...` trigger of an upgrade codeunit IS upgrade code regardless of where it lives, and the upgrade rules apply to it.
## Best Practice
Place every piece of upgrade logic in a codeunit declared with `Subtype = Upgrade;` and expose entry points via the two triggers `OnUpgradePerCompany` and `OnUpgradePerDatabase`. Helper procedures may live in normal codeunits, but they inherit the upgrade-context rules (guarded reads, no external calls, upgrade tags, etc.) when called from an upgrade trigger.
See sample: `upgrade-codeunit-subtype.good.al`.
## Anti Pattern
Putting upgrade-style logic in a regular codeunit that the platform never invokes during upgrade — for example a normal codeunit with a manually invented "RunUpgrade" procedure that nothing wires to the upgrade pipeline. The migration code will simply not run.
See sample: `upgrade-codeunit-subtype.bad.al`.

View file

@ -1,22 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,28 +0,0 @@
---
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 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 when a new field added to an existing table needs initialization across existing rows, and for any table that can contain more than 300,000 records. Tables in the ledger-entry and document-line category reliably exceed this threshold; treat them as requiring DataTransfer by default.
Set tables, add source filters, add constant values, call CopyFields, clear, and repeat for additional slices. Use the pattern for new fields and tables added in the same change. If no new field or table is involved, document why validation triggers and event subscribers are safe to bypass, or keep the explicit loop that invokes the business logic.
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

@ -1,11 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,26 +0,0 @@
---
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

@ -1,4 +1,4 @@
codeunit 50803 "Upgrade Sample TagGuard Bad"
codeunit 50209 "Upgrade Tag Driven"
{
Subtype = Upgrade;
@ -8,20 +8,18 @@ codeunit 50803 "Upgrade Sample TagGuard Bad"
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
// Version-coupled branching breaks when a tenant skips a version.
if AppInfo.DataVersion().Major > 14 then
exit;
if AppInfo.DataVersion().Major < 14 then
UpgradeFeatureA()
else if AppInfo.DataVersion().Major < 17 then
UpgradeFeatureB()
else
if AppInfo.DataVersion().Major < 21 then
UpgradeFeatureB();
exit;
end;
local procedure UpgradeFeatureA()
begin
end;
local procedure UpgradeFeatureB()
begin
end;
local procedure UpgradeFeatureA() begin end;
local procedure UpgradeFeatureB() begin end;
}

View file

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

View file

@ -1,26 +1,31 @@
---
bc-version: [all]
domain: upgrade
keywords: [upgrade-tag, dataversion, version-check, idempotent, guard]
keywords: [upgrade-tag, version-check, dataversion, has-upgrade-tag, set-upgrade-tag, control-flow]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Guard upgrade steps with upgrade tags, not version checks
# Control upgrade execution 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.
Each piece of upgrade logic must run exactly once per company (or database) across the lifetime of an extension. The platform mechanism for that is the `Upgrade Tag` codeunit: a procedure asks `HasUpgradeTag(MyTag())` at entry, performs its work, then calls `SetUpgradeTag(MyTag())` to record completion. Subsequent upgrades on the same tenant see the tag and skip the work. Hand-rolled `if MyApp.DataVersion().Major < N then ...` chains are the wrong tool: they are version-coupled, accumulate stale branches over time, and break when a tenant skips a version.
## Best Practice
Guard each standard upgrade 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`). The supported DataVersion exception is first-install detection in `OnInstallAppPerCompany` with the `0.0.0.0` sentinel; one-time Hybrid migration codeunits follow separate migration patterns and should not be forced into ordinary upgrade-tag structure.
Every upgrade procedure starts with a `HasUpgradeTag` guard and ends with `SetUpgradeTag` once the work is committed. Each feature gets its own tag string so features can be re-run independently if needed.
See sample: `use-upgrade-tags-not-version-checks.good.al`.
## Anti Pattern
`if MyApp.DataVersion().Major < 18 then UpgradeFeatureA();` inside a standard upgrade step — 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.
Branching on `MyApp.DataVersion().Major > N`, or chains of `< N` / `< M` to decide which upgrade step to run. Such code becomes unmaintainable after a few releases and silently does the wrong thing on tenants that skip versions.
See sample: `use-upgrade-tags-not-version-checks.bad.al`.
## See also
- `first-install-dataversion-zero-check.md` — the one situation where reading `DataVersion()` is the right call.
- `register-upgrade-tags-with-subscribers.md` — how to make a tag known to the platform.