Merge latest main into performance guidance

Preserve the reviewed performance package while incorporating the strengthened deterministic review-fixture coverage contract.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-09-29 17:20:38 +02:00
commit b67e048070
177 changed files with 5475 additions and 54 deletions

View file

@ -30,9 +30,10 @@ function Assert-ThrowsLike {
}
$generator = Join-Path $Root 'tools/Build-SkillIndex.ps1'
$resolver = Join-Path $Root 'tools/Resolve-SkillWorklist.ps1'
$indexSchema = Join-Path $Root 'schemas/skill-index.schema.json'
$reportSchema = Join-Path $Root 'schemas/findings-report.schema.json'
foreach ($path in $generator, $indexSchema, $reportSchema) {
foreach ($path in $generator, $resolver, $indexSchema, $reportSchema) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Required contract file not found: $path"
}
@ -235,9 +236,83 @@ Output.
Assert-ThrowsLike -Pattern '*Nested super-skills are not supported*' -Action {
& $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'nested.json')
}
Remove-Item -LiteralPath (Join-Path $fixtureSkills 'al-nested-review.md') -Force
$validSuper = @'
---
kind: action-skill
id: al-code-review
version: 1
title: Review
description: Test super-skill.
inputs: [file-path]
outputs: [findings-report]
sub-skills:
- microsoft/skills/review/al-leaf-review.md
---
# Review
## Source
Source.
## Relevance
Relevance.
## Worklist
Worklist.
## Action
Action.
## Output
Output.
'@
Set-Content -LiteralPath $superPath -Value $validSuper -Encoding utf8NoBOM
$customSkills = Join-Path -Path $fixtureRoot -ChildPath 'custom/skills/review'
New-Item -ItemType Directory -Path $customSkills -Force | Out-Null
$customLeaf = $leaf.Replace('title: Leaf', 'title: Custom Leaf')
Set-Content -LiteralPath (Join-Path $customSkills 'custom-leaf-review.md') -Value $customLeaf -Encoding utf8NoBOM
$layeredPath = Join-Path $tmp 'layered.json'
& $generator -BCQualityRoot $fixtureRoot -IndexPath $layeredPath | Out-Null
$layeredIndex = Get-Content -LiteralPath $layeredPath -Raw | ConvertFrom-Json
$layeredLeaves = @($layeredIndex.skills | Where-Object id -eq 'al-leaf-review')
if ($layeredLeaves.Count -ne 2) {
throw "Expected both layered al-leaf-review implementations, found $($layeredLeaves.Count)."
}
if ((@($layeredLeaves.layer | Sort-Object) -join ',') -cne 'custom,microsoft') {
throw 'Layered al-leaf-review implementations did not preserve custom and microsoft records.'
}
$resolved = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath (
'microsoft/skills/review/al-code-review.md'
)
if ($resolved.subSkills.Count -ne 1 -or
$resolved.subSkills[0].path -cne 'custom/skills/review/custom-leaf-review.md') {
throw 'The custom implementation did not win the layered leaf slot.'
}
$microsoftOnly = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath (
'microsoft/skills/review/al-code-review.md'
) -EnabledLayers microsoft
if ($microsoftOnly.subSkills.Count -ne 1 -or
$microsoftOnly.subSkills[0].path -cne 'microsoft/skills/review/al-leaf-review.md') {
throw 'Disabling the custom layer did not fall back to the Microsoft implementation.'
}
$customDisabled = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath (
'microsoft/skills/review/al-code-review.md'
) -DisabledSkills 'custom/skills/review/custom-leaf-review.md'
if ($customDisabled.subSkills.Count -ne 1 -or
$customDisabled.subSkills[0].path -cne 'microsoft/skills/review/al-leaf-review.md') {
throw 'Disabling the custom implementation did not fall back to Microsoft.'
}
Set-Content -LiteralPath (Join-Path $customSkills 'duplicate-leaf-review.md') -Value $customLeaf -Encoding utf8NoBOM
Assert-ThrowsLike -Pattern '*Duplicate action-skill IDs within a layer: custom:al-leaf-review*' -Action {
& $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'duplicate-layer.json')
}
}
finally {
Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Output "Skill-index check PASSED: deterministic, schema-valid, and all $($expectedLeaves.Count) review leaves preserved in order."
Write-Output "Skill-index check PASSED: deterministic, schema-valid, layered overrides resolved, and all $($expectedLeaves.Count) review leaves preserved in order."

View file

@ -688,12 +688,16 @@ def run(root: Path) -> Report:
if domain_dir.is_dir():
validate_samples_in_domain(domain_dir, root, report)
# Third pass: R24 unique ids within kind
# Third pass: R24 unique ids within kind. Layered action-skill overrides
# may share an id, but two definitions in one layer are ambiguous.
by_kind: dict[str, dict[str, list[Path]]] = {}
for rec in skill_records:
if rec.skill_id is None:
continue
by_kind.setdefault(rec.kind, {}).setdefault(rec.skill_id, []).append(rec.path)
scope = rec.kind
if rec.kind == "action-skill":
scope = f"{rec.kind}:{rec.path.relative_to(root).parts[0]}"
by_kind.setdefault(scope, {}).setdefault(rec.skill_id, []).append(rec.path)
for kind, by_id in by_kind.items():
for sid, paths in by_id.items():
if len(paths) > 1:

View file

@ -12,7 +12,26 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Collect changed paths
shell: pwsh
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
$paths = if (-not $env:BASE_SHA -or $env:BASE_SHA -match '^0+$') {
@(git diff-tree --no-commit-id --name-only -r $env:GITHUB_SHA)
} else {
@(git diff --name-only $env:BASE_SHA $env:GITHUB_SHA)
}
$paths | Set-Content -LiteralPath "$env:RUNNER_TEMP/changed-paths.txt" -Encoding utf8NoBOM
- name: Validate review evaluation corpus
shell: pwsh
run: ./tools/Test-ReviewFixtures.ps1 -Root . -PrepareDirectory "$env:RUNNER_TEMP/bcquality-review-fixtures"
run: |
./tools/Test-ReviewFixtures.ps1 -Root . `
-PrepareDirectory "$env:RUNNER_TEMP/bcquality-review-fixtures" `
-ChangedPathsFile "$env:RUNNER_TEMP/changed-paths.txt" `
-CoverageReportPath "$env:RUNNER_TEMP/review-coverage.json"
Get-Content -LiteralPath "$env:RUNNER_TEMP/review-coverage.json"

View file

@ -44,6 +44,15 @@ Otherwise the layers are additive. A matching filename alone does not suppress
an article; the [READ contract](../skills/read.md#layer-precedence) governs
knowledge conflicts. Review reports record displaced knowledge in `suppressed`.
Action skills use the same layer order but override by frontmatter `id`. A
custom leaf with the same `id` as a Community or Microsoft leaf replaces that
leaf in every super-skill slot while its layer is enabled. The files may have
different names. IDs must remain unique within each layer. Disabling the custom
layer or the custom skill path makes composition fall back to the next enabled
implementation. Hosts should build the skill index and use
`tools/Resolve-SkillWorklist.ps1`; they must not implement this selection from
filenames.
Layer selection is **not an access-control boundary**. A plugin installation
still contains excluded layers on disk. An integration requiring genuine
exclusion must remove denied files from its own content copy before the agent

View file

@ -49,16 +49,20 @@ only result.
action skills to run. Do not reproduce its routing logic.
3. Execute every dispatched action skill with the exact input subset in its
dispatch record. Read `skills/read.md` and `skills/do.md` on demand.
4. When an action skill declares `sub-skills`, execute every relevant leaf as a
discrete invocation. Leaves are independent and may be scheduled serially
or concurrently.
4. When an action skill declares `sub-skills`, resolve its ordered leaf slots
with `tools/Resolve-SkillWorklist.ps1`, passing the enabled layers and
disabled skill paths from the task context. Execute every resolved leaf as
a discrete invocation. Leaves are independent and may be scheduled serially
or concurrently.
5. Capture the exact Task return as the immutable raw audit payload and primary
transport. Preserve it unchanged in private artifacts or host logs. Before
the full DO acceptance gate, create a normalized candidate only for DO's
bounded optional-range case, record that normalization separately in private
telemetry, and accept the candidate only if the entire copy passes the
unchanged strict gate. The accepted report contains no undeclared telemetry
fields.
unchanged strict gate. Use `tools/Validate-FindingsReport.ps1`, passing the
exact source paths and fully retrieved article paths; pass `-SkillKind super`
for the final rolled-up report. The accepted report contains no undeclared
telemetry fields.
6. Collect each accepted findings-report into `sub-results` in the declared
`sub-skills` order, not completion order. Run the super-skill self-review
only after all leaves have finished.
@ -92,6 +96,8 @@ A compatible runner:
- invokes every worklisted leaf exactly once unless a documented retry replaces
a failed attempt;
- resolves same-ID leaf implementations by `custom > community > microsoft`,
preserves declared slot order, and falls back when a higher layer is disabled;
- keeps leaf contexts isolated and passes only the inputs they declare;
- preserves each raw Task return unchanged for audit and distinguishes it from
any normalized accepted copy;

View file

@ -4,6 +4,15 @@ The evaluation is convention-driven. The harness discovers every `<layer>/skills
`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different `article`, add context when the generic convention cannot express a scenario, or use an `articles` array when one domain needs explicit regression coverage for several paired articles. Specify either `article` or `articles`, not both. The first selected article retains the stable `<domain>-bad` and `<domain>-good` manifest IDs; additional articles use slug-qualified IDs. Overrides should remain empty in the normal case.
CI also measures selected paired articles against every effective article that
has both AL companions. A changed paired article must be selected by the
domain convention or an override. When adding it would not provide a useful
deterministic regression, add a narrow `coverageWaivers` entry with its exact
article path and a non-empty reason. Waivers are reviewable exceptions, not a
substitute for domain coverage. The generated coverage report includes totals
and per-domain ratios; the ratio is informational, while changed-file coverage
is mandatory.
Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer.
The SCM `articles` override deliberately selects every rule in the initial
@ -36,6 +45,13 @@ pwsh ./tools/Test-ReviewFixtures.ps1 -Root .
This credential-free check proves every selected leaf maps to a same-named knowledge domain with at least one complete AL sample pair and that all configured overrides are valid.
To reproduce the changed-file gate and emit the same measurable report as CI:
```powershell
git diff --name-only origin/main...HEAD | Set-Content .changed-paths.txt
pwsh ./tools/Test-ReviewFixtures.ps1 -Root . -ChangedPathsFile .changed-paths.txt -CoverageReportPath .coverage.json
```
## Run a fast-model evaluation
1. Prepare neutral inputs:

View file

@ -3,6 +3,7 @@
"selection": "first-paired-al-article",
"minimumExpectedRecall": 1.0,
"minimumCleanRate": 1.0,
"coverageWaivers": [],
"overrides": {
"agents": {
"article": "wire-all-three-agent-interfaces"
@ -41,11 +42,16 @@
"store-scheduled-task-id-to-avoid-duplicate-tasks",
"design-covering-keys-from-read-pattern",
"review-overlapping-keys-before-adding-an-index",
"setcurrentkey-sets-sort-order-not-index-hint",
"preserve-buffered-inserts-by-separating-target-reads",
"aggregate-before-persisting-intermediate-results",
"cache-repeated-filtered-results-with-explicit-scope",
"avoid-repeating-unchanged-validation",
"avoid-get-inside-loop-on-large-table",
"isempty-before-findset-is-extra-round-trip",
"query-results-bypass-primary-key-cache",
"calcsums-instead-of-calcfields-in-loop",
"use-setautocalcfields-for-per-row-flowfields",
"temporary-tables-have-no-database-cost",
"use-setloadfields-for-partial-records",
"prefer-modifyall-over-per-row-modify"
@ -85,7 +91,14 @@
"reconcile-warehouse-adjustments-with-the-item-ledger",
"post-transfers-through-shipment-and-receipt-codeunits",
"use-date-aware-availability-for-promising",
"carry-out-requisition-actions-through-the-standard-workflow"
"carry-out-requisition-actions-through-the-standard-workflow",
"derive-base-quantities-through-the-line-unit-of-measure"
]
},
"security": {
"articles": [
"al-has-no-built-in-htmlencode",
"do-not-concatenate-external-text-into-setfilter"
]
},
"style": {
@ -98,14 +111,23 @@
"article": "telemetry-event-id-stable-unique"
},
"testing": {
"article": "ui-handlers-in-tests"
"articles": [
"ui-handlers-in-tests",
"reset-per-test-state-before-the-isinitialized-guard"
]
},
"upgrade": {
"article": "initvalue-does-not-update-existing-rows",
"context": "The extended table existed in the previous app version and already contains rows."
},
"web-services": {
"article": "expose-systemid-as-the-api-key"
"articles": [
"expose-systemid-as-the-api-key",
"handle-httpclient-platform-failure-before-response-access",
"check-http-status-before-consuming-response-body",
"check-json-null-before-converting-values",
"format-exchanged-values-with-standard-format-9"
]
}
}
}

View file

@ -0,0 +1,13 @@
codeunit 50104 "Import File Reader"
{
procedure ImportFile()
var
ImportFile: File;
InStream: InStream;
begin
ImportFile.WriteMode(false);
ImportFile.TextMode(true);
ImportFile.Open('C:\Import\data.txt'); // fails in SaaS — no local filesystem
ImportFile.CreateInStream(InStream);
end;
}

View file

@ -0,0 +1,24 @@
codeunit 50104 "Import File Reader"
{
procedure ImportFile()
var
TempBlob: Codeunit "Temp Blob";
FromInStream: InStream;
ToOutStream: OutStream;
InStream: InStream;
begin
if not UploadIntoStream('All Files (*.*)|*.*', FromInStream) then
exit;
TempBlob.CreateOutStream(ToOutStream);
CopyStream(ToOutStream, FromInStream);
TempBlob.CreateInStream(InStream);
ParseStream(InStream);
end;
local procedure ParseStream(var InStream: InStream)
begin
// parse InStream content here
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: appsource
keywords: [file-datatype, saas, onprem, uploadintostream, downloadfromstream, instream, outstream, streaming]
technologies: [al]
countries: [w1]
application-area: [all]
---
# The File data type's direct I/O methods are OnPrem-only
> Contributions welcome — open a PR to refine or extend this article.
## Description
The classic `File` variable type — `Open`/`Create`/`Read`/`Write`/`Close` against a path on the local or server filesystem — is scoped OnPrem-only. Code targeting Business Central Online that calls `File.Open`, `File.Create`, `File.Read`, or `File.Write` fails to compile against a Cloud-scoped project; it does not compile successfully and fail or get silently skipped at runtime. Separately, and regardless of the compile-time scoping, no server/local filesystem path is available to an extension actually running in Business Central Online.
## Best Practice
Use the stream-based equivalents: `UploadIntoStream` to read user-selected file content into an `InStream`, and `DownloadFromStream` to write an `OutStream`'s content to a file the user saves. Stage the content in a `TempBlob` between the stream and the rest of the parsing/formatting code.
See sample: [`file-datatype-saas.good.al`](file-datatype-saas.good.al).
## Anti Pattern
Opening a hardcoded or user-supplied filesystem path with the `File` variable type. This is a strong signal the code was written for on-premises only, or copied from material that predates the cloud-first streaming APIs.
See sample: [`file-datatype-saas.bad.al`](file-datatype-saas.bad.al).

View file

@ -0,0 +1,37 @@
---
bc-version: [all]
domain: appsource
keywords: [version, release, app-json, semver, al-go, appsource]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Update the app version at every release
## Description
At every release — a branch merged to `main`, a tagged release build, or an AppSource submission — the app's version is consciously updated, not left to the pipeline alone.
| Version part | Owner | When |
|---|---|---|
| Major | Developer decision | Breaking change (schema, API, removed objects) |
| Minor | Developer decision | Every release with new functionality |
| Build / Revision | AL-Go pipeline | Automatic — never hand-edited |
The app's stable identity is its `id` in `app.json`; the version identifies which release — which code state — of that app is deployed. Two customer environments running "the same" version with different code is an undiagnosable support case. AppSource's actual requirement is strict full-version ordering — the complete version must be greater than the previously submitted version — which an AL-Go-generated build/revision increment can satisfy on its own; AppSource does not require major.minor itself to change. Treating major.minor as a deliberate, human-decided compatibility signal is still valuable practice — it is a statement about what changed that no pipeline can make on its own — just not a platform-enforced requirement.
## Best Practice
Before the release merge:
app.json: "version": "1.3.0.0" (new functionality -> minor bump, by team convention)
AL-Go settings: "repoVersion": "1.3" (where used)
Then: feature branch -> main via PR, tag, release.
"Feature branches never touch the version" and "every merge to main is a release" are workflow choices your team can adopt for compatibility clarity — not something AppSource itself requires.
## Anti Pattern
Branch merged to main and released.
app.json still says "version": "1.2.0.0" -- same as the previous release.
Two different code states now share one version identity.

View file

@ -0,0 +1,9 @@
codeunit 50103 "Order Confirmation Notifier"
{
procedure Send(ToAddress: Text; Subject: Text; Body: Text)
var
Mail: Codeunit Mail;
begin
Mail.CreateMessage(ToAddress, '', '', Subject, Body, false, false);
end;
}

View file

@ -0,0 +1,11 @@
codeunit 50103 "Order Confirmation Notifier"
{
procedure Send(ToAddress: Text; Subject: Text; Body: Text)
var
Email: Codeunit Email;
EmailMessage: Codeunit "Email Message";
begin
EmailMessage.Create(ToAddress, Subject, Body, true);
Email.Send(EmailMessage, Enum::"Email Scenario"::Default);
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: breaking-changes
keywords: [email, codeunit-mail, email-message, email-scenario, email-account, smtp, sending-email]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Send email through the Email module, not Codeunit Mail (397)
> Contributions welcome — open a PR to refine or extend this article.
## Description
Older AL code sends email by calling `Codeunit Mail (397)`. Business Central's current extensibility model is a different, richer object set — `Codeunit Email`, `Codeunit "Email Message"`, `enum "Email Scenario"`, and the `Email Account`/`Email Connector` interface (Microsoft 365, Current User, SMTP, or a custom connector). `Codeunit "Email Message"` is the in-memory object you build the message on; it is not itself the persisted Sent/Outbox/Draft record — that storage is managed separately once the message is queued or sent. New code built on `Codeunit Mail` inherits its SMTP-era, single-connector assumptions and leaves no Sent/Outbox trail behind.
## Best Practice
Build on `Codeunit Email` and `Codeunit "Email Message"`. Route the message through an `Email Scenario` so different document types can use different accounts without the calling code needing to know which account that is, and get a tracked Sent/Outbox/Draft record for free.
See sample: [`prefer-email-module.good.al`](prefer-email-module.good.al).
## Anti Pattern
Calling `Codeunit Mail`'s `CreateMessage`. It still compiles and runs, but current `Codeunit Mail`'s own implementation of `CreateMessage` no longer sends anything by itself — it only raises integration events for a legacy subscriber to act on — so building new code on it means depending on whatever compatibility shim happens to still be wired up, with no first-class connector selection and no queryable Sent/Outbox/Draft record. `Send` and `GetErrorDesc` are not current members of `Codeunit Mail` at all; do not reference them.
See sample: [`prefer-email-module.bad.al`](prefer-email-module.bad.al).

View file

@ -0,0 +1,22 @@
codeunit 50101 "Meter Jnl.-Post"
{
procedure Post(var MeterJnlLine: Record "Meter Journal Line")
var
MeterLedgEntry: Record "Meter Ledger Entry";
begin
// Validation, Journal access, and posting all mixed in one routine.
if not Confirm('Post journal lines?') then
exit;
if MeterJnlLine.FindSet() then
repeat
if MeterJnlLine.Quantity = 0 then
Error('Quantity must not be zero.');
MeterLedgEntry.Init();
MeterLedgEntry.TransferFields(MeterJnlLine);
MeterLedgEntry.Insert();
MeterJnlLine.Delete();
until MeterJnlLine.Next() = 0;
end;
}

View file

@ -0,0 +1,42 @@
codeunit 50100 "Meter Jnl.-Check Line"
{
procedure CheckLine(var MeterJnlLine: Record "Meter Journal Line")
begin
// Reads setup/dimension data only, shows no UI beyond errors.
if MeterJnlLine.Quantity = 0 then
Error('Quantity must not be zero.');
end;
}
codeunit 50101 "Meter Jnl.-Post Line"
{
procedure PostLine(var MeterJnlLine: Record "Meter Journal Line")
var
MeterLedgEntry: Record "Meter Ledger Entry";
begin
// Posts exactly one journal line; never touches the Journal table.
MeterLedgEntry.Init();
MeterLedgEntry.TransferFields(MeterJnlLine);
MeterLedgEntry.Insert();
end;
}
codeunit 50102 "Meter Jnl.-Post Batch"
{
procedure PostBatch(var MeterJnlLine: Record "Meter Journal Line")
var
CheckLine: Codeunit "Meter Jnl.-Check Line";
PostLine: Codeunit "Meter Jnl.-Post Line";
begin
if MeterJnlLine.FindSet() then
repeat
CheckLine.CheckLine(MeterJnlLine);
until MeterJnlLine.Next() = 0;
if MeterJnlLine.FindSet() then
repeat
PostLine.PostLine(MeterJnlLine);
MeterJnlLine.Delete();
until MeterJnlLine.Next() = 0;
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: data-modeling
keywords: [posting-routine, check-line, post-line, post-batch, companion-codeunit, yes-no-wrapper, journal]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Split posting routines into Check Line / Post Line / Post Batch
> Contributions welcome — open a PR to refine or extend this article.
## Description
Business Central's own journal-based posting routines consistently follow a three-codeunit split, each with one primary responsibility — `Codeunit "Gen. Jnl.-Check Line"` / `"Gen. Jnl.-Post Line"` / `"Gen. Jnl.-Post Batch"` for the general journal, and the same `<Journal>-Check Line` / `<Journal>-Post Line` / `<Journal>-Post Batch` shape repeated for Item, Resource, Job, Fixed Asset, Insurance, and Cost Accounting journals: `Check Line` validates one line, `Post Line` posts exactly one journal line — `Gen. Jnl.-Post Line` itself can write more than one G/L Entry per call (a balancing entry, VAT, currency rounding, deferrals), so "exactly one line" describes its input, not a one-entry-out guarantee — and `Post Batch` loops both across the journal. A document posting routine (posting one document at a time) calls `Post Line` directly and skips `Post Batch`. This is the standard shape to evaluate a new journal-based posting routine against, not a platform-enforced constraint — a routine with a genuinely different transaction/reuse shape may legitimately organize itself differently, and the three codeunits' responsibilities are a useful default split, not a guarantee that every implementation keeps them non-overlapping. But a new routine that blurs this split without a specific reason either misses functionality other code expects to call directly, or exposes an interaction surface it shouldn't.
## Best Practice
`Check Line` reads setup/dimension data only on its first call and shows no UI beyond errors. `Post Line` only operates on the record passed to it — never the Journal table — so it can be called directly by other posting code, including a document posting routine. `Post Batch` is the only one of the three that reads and updates the Journal table, and it is the only one invoked from the Post action on a journal page. A `-Post` document codeunit is never called directly from a page; a page calls a `-Post (Yes/No)` confirmation wrapper instead, so the same `-Post` codeunit can also run unattended from a batch-posting report.
See sample: [`check-post-line-batch-pattern.good.al`](check-post-line-batch-pattern.good.al).
## Anti Pattern
A single monolithic posting codeunit that reads the Journal table, validates lines, writes ledger entries, and shows confirmation dialogs all in one procedure. It cannot be reused by another posting routine without fabricating journal records, and it cannot run unattended because it insists on user interaction.
See sample: [`check-post-line-batch-pattern.bad.al`](check-post-line-batch-pattern.bad.al).

View file

@ -0,0 +1,9 @@
codeunit 50101 "Batch Job Runner"
{
procedure AdvanceToNextBusinessDay()
begin
// Anti-pattern: repurposes the user's session WorkDate as a
// scratch variable for unrelated business logic.
WorkDate(CalcDate('<1D>', WorkDate()));
end;
}

View file

@ -0,0 +1,11 @@
codeunit 50100 "Posting Date Helper"
{
procedure GetDefaultPostingDate(): Date
var
PostingDate: Date;
begin
// Read the work date to default a value; never write to it.
PostingDate := WorkDate();
exit(PostingDate);
end;
}

View file

@ -0,0 +1,58 @@
---
bc-version: [all]
domain: data-modeling
keywords: [workdate, session-setting, user-control, side-effect]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Application code must not change the WorkDate
## Description
The work date is a per-user session setting the user controls from the
client (the date shown in the top-right corner, used to default posting
dates and date filters). Business logic unrelated to that setting must not
call `WorkDate(NewDate)` as a side effect of doing something else — that
silently changes what the user sees and defaults to for the rest of their
session, a surprising, hard-to-trace behavior change the user never asked
for and has no visibility into. This is not a blanket ban on the setter
itself: BCApps' own demo-data generators legitimately save the current
work date, set a specific one to backdate the data they create, and
restore it afterward (see `CreateDemoEDocsBE.Codeunit.al`'s
`WorkDate(SampleInvoiceDate)` / `WorkDate(SavedWorkDate)` pair), and test
codeunits routinely set `WorkDate` deliberately to control the date context
a test runs under (hundreds of calls across BCApps' test suite, for
example `SustainabilityPostingTest.Codeunit.al`). Both are the code's
*actual purpose*, not a side effect of something unrelated.
This is a call-direction distinction for the read side: reading the
current work date via `WorkDate` (or `WorkDate()` with no argument) is
always fine.
## Best Practice
Read the work date to default a value. Only write to it when changing it
*is* the operation being performed — implementing the user's own
work-date/settings action, or a test or demo-data routine that deliberately
establishes a date context (saving and restoring the prior value if the
routine must leave the session as it found it). Business logic that exists
to do something else must never write `WorkDate` as an incidental side
effect; if a calculation needs a specific date, pass or compute that date
as a local variable instead.
See sample: [`code-must-not-change-workdate.good.al`](code-must-not-change-workdate.good.al).
## Anti Pattern
Setting the work date from within a codeunit, report, or page action whose
purpose is unrelated to the user's date preference — for example, a
posting or calculation routine that calls `WorkDate(SomeDate)` to make its
own logic simpler. This changes session state the user owns for the
duration of a call that was never about the work date, and never restores
it. This is a different case from a test or demo-data routine explicitly
declaring a date context: the anti-pattern is unrelated logic silently
mutating state it does not own, not the setter form itself.
See sample: [`code-must-not-change-workdate.bad.al`](code-must-not-change-workdate.bad.al).

View file

@ -0,0 +1,13 @@
table 50100 "Course"
{
fields
{
field(1; "No."; Code[20]) { }
field(10; "Global Dimension 1 Code"; Code[20])
{
// No CaptionClass, no OnValidate call into DimensionManagement.
// Accepts any value; never becomes a Default Dimension record.
TableRelation = "Dimension Value".Code;
}
}
}

View file

@ -0,0 +1,89 @@
// Master data: Default Dimension records, no Dimension Set ID field.
table 50100 "Course"
{
fields
{
field(1; "No."; Code[20]) { }
field(10; "Global Dimension 1 Code"; Code[20])
{
CaptionClass = '1,1,1';
TableRelation = "Dimension Value".Code where(
"Global Dimension No." = const(1), Blocked = const(false));
trigger OnValidate()
var
DimMgt: Codeunit DimensionManagement;
begin
DimMgt.ValidateDimValueCode(1, "Global Dimension 1 Code");
DimMgt.SaveDefaultDim(Database::Course, "No.", 1, "Global Dimension 1 Code");
end;
}
}
trigger OnDelete()
var
DimMgt: Codeunit DimensionManagement;
begin
DimMgt.DeleteDefaultDim(Database::Course, "No.");
end;
}
// Transactional/document data: a single Dimension Set ID, inherited from the
// related master record and overridable via shortcut dimension fields.
table 50101 "Course Registration Header"
{
fields
{
field(1; "No."; Code[20]) { }
field(2; "Customer No."; Code[20])
{
TableRelation = Customer;
trigger OnValidate()
begin
UpdateDimensionSetID();
end;
}
field(10; "Shortcut Dimension 1 Code"; Code[20])
{
CaptionClass = '1,1,1';
TableRelation = "Dimension Value".Code where(
"Global Dimension No." = const(1), Blocked = const(false));
trigger OnValidate()
var
DimMgt: Codeunit DimensionManagement;
begin
DimMgt.ValidateShortcutDimValues(1, "Shortcut Dimension 1 Code", "Dimension Set ID");
end;
}
field(480; "Dimension Set ID"; Integer)
{
Editable = false;
TableRelation = "Dimension Set Entry"."Dimension Set ID";
}
}
local procedure UpdateDimensionSetID()
var
Customer: Record Customer;
DimMgt: Codeunit DimensionManagement;
DefaultDimSource: List of [Dictionary of [Integer, Code[20]]];
GlobalDim2Code: Code[20];
begin
// Recompute from scratch (InheritFromDimSetID = 0) whether or not the
// customer lookup succeeds. Passing the existing "Dimension Set ID"
// here would inherit dimensions from whichever record the document
// was previously linked to, and exiting early on a failed Get would
// leave that same stale data in place — both defeat the point of
// this procedure. Clearing the shortcut field and recomputing with
// an empty source list (when the customer doesn't exist) correctly
// clears the document's dimensions instead of leaving old ones.
"Shortcut Dimension 1 Code" := '';
if Customer.Get("Customer No.") then
DimMgt.AddDimSource(DefaultDimSource, Database::Customer, "Customer No.");
"Dimension Set ID" :=
DimMgt.GetDefaultDimID(
DefaultDimSource, '', "Shortcut Dimension 1 Code", GlobalDim2Code, 0, 0);
end;
}

View file

@ -0,0 +1,35 @@
---
bc-version: [all]
domain: data-modeling
keywords: [dimensions, dimensionmanagement, global-dimension, shortcut-dimension, default-dimension, validatedimvaluecode, getdefaultdimid]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Wire dimension support through DimensionManagement, not ad hoc fields
> Contributions welcome — open a PR to refine or extend this article.
## Description
Adding dimension support to a custom table is not just a matter of adding a `Code[20]` field, and master tables and document/transactional tables wire into `Codeunit "Dimension Management"` through two different models — treating them as one mechanism is itself the mistake this article corrects:
- **Master data** (a custom master table, e.g. "Course") persists **Default Dimension** records: each shortcut dimension field validates through `ValidateDimValueCode`, then the result is saved via `SaveDefaultDim`, and `DeleteDefaultDim` removes them again in `OnDelete`. Both `ValidateDimValueCode` and `SaveDefaultDim` take the shortcut dimension *number* (1-8, matching `General Ledger Setup`'s "Shortcut Dimension N Code" fields) as their first/third argument respectively — not the AL field ID of the table field being validated. The master record itself carries no `Dimension Set ID` field.
- **Transactional/document data** (a custom document or journal-line table) carries a single **`Dimension Set ID`** field — a pointer to a shared, deduplicated set of dimension values in `Dimension Set Entry`, assembled from whatever the document inherited plus whatever the user overrode. A document does not acquire that ID by calling `SaveDefaultDim`; it builds a source list with `AddDimSource` (naming the related master table and its key, e.g. `Database::Customer`), then calls `GetDefaultDimID` to compute a new `Dimension Set ID` that inherits the master's Default Dimension records. Editing a shortcut dimension field directly on the document validates through `ValidateShortcutDimValues`, which updates the same `Dimension Set ID` in place rather than writing a separate Default Dimension record.
Skipping the model that actually matches the table's kind produces a field that looks correct in the designer but silently fails to save, validate, or carry through to postings — or, for a document, one that never picks up the customer's/vendor's own dimensions at all.
## Best Practice
For a master table, validate each shortcut dimension field through `ValidateDimValueCode`, save the result with `SaveDefaultDim`, and delete the matching Default Dimension records in `OnDelete`.
For a document table, when the field that attaches the document to a master record changes (e.g. `Customer No.`), call `AddDimSource` naming that master table and key, then `GetDefaultDimID` to compute the document's new `Dimension Set ID`, inheriting the master's Default Dimension records. Pass `0` for `GetDefaultDimID`'s `InheritFromDimSetID` argument in this case — passing the document's *existing* `Dimension Set ID` instead inherits whatever dimensions were already in it, so a value the previous linked record supplied can survive into the new one even where the new record has no default for that dimension. Run this same recompute — clear the shortcut field, call `GetDefaultDimID` with no source added — when the lookup on the new key fails (blank or an invalid value), too: exiting early instead leaves the previous record's dimensions in place, which is the same staleness bug the `InheritFromDimSetID = 0` rule exists to prevent. Validate the document's own Shortcut Dimension fields through `ValidateShortcutDimValues`, which updates that same `Dimension Set ID` in place rather than persisting a separate Default Dimension record.
See sample: [`dimension-management-wiring.good.al`](dimension-management-wiring.good.al).
## Anti Pattern
Adding a dimension-looking field with only a `TableRelation` to Dimension Value, and no call into `DimensionManagement` at all. The field accepts input but never becomes a real Default Dimension record, so it does not validate against blocked values and does not flow into postings.
See sample: [`dimension-management-wiring.bad.al`](dimension-management-wiring.bad.al).

View file

@ -0,0 +1,14 @@
table 50100 "Period Stats"
{
fields
{
field(1; "Period Start"; Date) { }
field(2; Flow; Enum "Some Flow") { }
}
keys
{
// Table already shipped with key(PK; "Period Start").
// Adding Flow here breaks every upgrade with AS0009.
key(PK; Flow, "Period Start") { Clustered = true; }
}
}

View file

@ -0,0 +1,24 @@
table 50100 "Period Stats"
{
fields
{
field(1; "Period Start"; Date) { }
}
keys
{
key(PK; "Period Start") { Clustered = true; }
}
}
table 50101 "Period Stats By Flow"
{
fields
{
field(1; "Period Start"; Date) { }
field(2; Flow; Enum "Some Flow") { }
}
keys
{
key(PK; Flow, "Period Start") { Clustered = true; }
}
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: data-modeling
keywords: [primary-key, clustered-key, table-design, appsource, breaking-change, schema-upgrade]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Never Change a Published Table's Primary or Clustered Key Field List
> Contributions welcome — open a PR to refine or extend this article.
## Description
Once a table has shipped — to AppSource, or to any customer environment that has already upgraded onto it — its primary key, and any other key marked `Clustered = true`, is frozen. This includes adding a field to the key, not only removing or reordering one: Business Central identifies existing rows by their key value, so any change to which fields compose that key invalidates every row already stored under the old shape, and the platform's upgrade validation rejects it outright (`AS0009`). This is easy to trip over because it doesn't look like the well-known "don't delete a field" mistake — the field being added is often brand new, and folding a new discriminating dimension straight into the existing key feels like the natural, un-denormalized way to model it. On an unpublished table that is correct; on a published one it is a breaking schema change regardless of which direction the field list changed, and there is no in-place fix once the upgrade is rejected, only reverting the key to its published shape.
## Best Practice
Leave a published table's key exactly as shipped. Model a new discriminating dimension as a separate table with its own key instead of adding a field to the existing key, and branch orchestration code by the new dimension rather than filtering one shared table on an extra key field.
See sample: [`do-not-change-primary-key.good.al`](do-not-change-primary-key.good.al).
## Anti Pattern
Adding a field to a published table's primary or clustered key to distinguish a new case. This fails AppSource validation or any customer upgrade with `AS0009` as soon as rows already exist under the old key shape, whether the field is being added, removed, or reordered.
See sample: [`do-not-change-primary-key.bad.al`](do-not-change-primary-key.bad.al).

View file

@ -0,0 +1,12 @@
codeunit 50130 "Sample Item Ledger Lookup"
{
procedure GetPostedItemLedgerEntries(var SalesHeader: Record "Sales Header"; var ItemLedgerEntry: Record "Item Ledger Entry")
var
LibrarySales: Codeunit "Library - Sales";
InvoiceNo: Code[20];
begin
InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, true, true);
ItemLedgerEntry.SetRange("Document No.", InvoiceNo);
if ItemLedgerEntry.FindSet() then;
end;
}

View file

@ -0,0 +1,13 @@
codeunit 50130 "Sample Item Ledger Lookup"
{
procedure GetPostedItemLedgerEntries(var SalesHeader: Record "Sales Header"; var ItemLedgerEntry: Record "Item Ledger Entry")
var
LibrarySales: Codeunit "Library - Sales";
ShippingNo: Code[20];
begin
LibrarySales.PostSalesDocument(SalesHeader, true, true);
ShippingNo := SalesHeader."Last Shipping No.";
ItemLedgerEntry.SetRange("Document No.", ShippingNo);
if ItemLedgerEntry.FindSet() then;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: data-modeling
keywords: [item-ledger-entry, document-no, last-shipping-no, ship-and-invoice, posting, sales-order]
technologies: [al]
countries: [w1]
application-area: [all]
---
# After Ship-and-Invoice posting, Item Ledger Entry carries the shipment document number
## Description
Posting a sales order with both Ship and Invoice in one call creates the Item Ledger Entry during the shipment leg of that combined post, so the entry's `Document No.` is stamped with the value assigned to the shipment — `Sales Header."Last Shipping No."` — not the posted sales invoice number the posting call returns. Code that filters Item Ledger Entry by the invoice number instead finds nothing: `SetRange`/`FindSet` simply return zero rows, with no error to signal the mistake.
## Best Practice
After posting a sales order with Ship and Invoice together, read `SalesHeader."Last Shipping No."` (populated during the post) and filter Item Ledger Entry by that value, not by the invoice number the posting routine returns.
See sample: [`item-ledger-entry-document-no-follows-last-shipping-no.good.al`](item-ledger-entry-document-no-follows-last-shipping-no.good.al).
## Anti Pattern
Filtering Item Ledger Entry by the posted sales invoice number after a combined Ship-and-Invoice post. The filter compiles and runs without error but matches zero rows, because the entry belongs to the shipment leg of the posting, not the invoice leg.
See sample: [`item-ledger-entry-document-no-follows-last-shipping-no.bad.al`](item-ledger-entry-document-no-follows-last-shipping-no.bad.al).

View file

@ -0,0 +1,11 @@
table 50111 "Sample Item Card"
{
fields
{
field(1; "No."; Code[20]) { }
field(50; Picture; BLOB)
{
Caption = 'Picture';
}
}
}

View file

@ -0,0 +1,11 @@
table 50110 "Sample Item Card"
{
fields
{
field(1; "No."; Code[20]) { }
field(50; Picture; Media)
{
Caption = 'Picture';
}
}
}

View file

@ -0,0 +1,49 @@
---
bc-version: [all]
domain: data-modeling
keywords: [blob, media, mediaset, picture-field, image-field, table-design]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Pictures must be stored in a Media/MediaSet field, not BLOB
## Description
`BLOB` is still a valid AL field type for arbitrary binary data, but it is
not the right choice for storing pictures or images. The current
recommendation is the `Media` field type for a single image, or
`MediaSet` when a record needs several independent images (e.g. multiple
product photos) — `MediaSet` is a collection of separately-imported media
objects, each with its own identity; it does not generate resized variants
or thumbnails on its own, and displaying more than one item still requires
custom page handling. Media/MediaSet integrate with the platform's
picture control and media repository, which a plain `BLOB` field does not
— but any derived preview or thumbnail image still has to be generated
explicitly and stored in its own field, regardless of which type holds the
source image.
`BLOB` remains the correct choice for genuinely arbitrary binary payloads
that are not images and don't benefit from the media pipeline (e.g. a raw
file attachment blob unrelated to picture rendering).
## Best Practice
Use `Media` for a single image, or `MediaSet` for multiple independent
images, for any field that holds a picture.
See sample: [`pictures-must-use-media-not-blob.good.al`](pictures-must-use-media-not-blob.good.al).
## Anti Pattern
A `BLOB` field named "Picture" compiles and stores the image bytes, but
it misses the picture control integration and media repository that a
`Media`/`MediaSet` field provides for free — the anti pattern is choosing
`BLOB` for image storage out of habit rather than recognizing that the
field is holding a picture, not generic binary data. A related anti
pattern: assuming `MediaSet` gives automatic image variants or thumbnails
because it sounds like a collection with derived versions — it is only a
collection of independently-imported media objects.
See sample: [`pictures-must-use-media-not-blob.bad.al`](pictures-must-use-media-not-blob.bad.al).

View file

@ -0,0 +1,17 @@
table 50121 "Sample Ledger Entry"
{
fields
{
// Anti-pattern: a Ledger table's key must never be user-editable.
field(1; "Entry No."; Integer) { }
field(2; "Posting Date"; Date) { }
field(3; Amount; Decimal) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
// No AutoIncrement, no guard against manual insert/delete — a user or
// integration can renumber or remove entries, breaking the Ledger
// type's audit-trail guarantee.
}

View file

@ -0,0 +1,16 @@
table 50120 "Sample Ledger Entry"
{
fields
{
// Ledger primary key: Integer "Entry No.", set only by posting.
field(1; "Entry No."; Integer) { AutoIncrement = true; }
field(2; "Posting Date"; Date) { }
field(3; Amount; Decimal) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
// No user-facing Insert/Delete/Modify path is exposed; rows are
// created exclusively by the posting routine.
}

View file

@ -0,0 +1,99 @@
---
bc-version: [all]
domain: data-modeling
keywords: [tables, table-design, naming-conventions, primary-key, master-table, ledger-table, journal-table, register-table, document-table, setup-table, subsidiary-table, supplemental-table]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Tables must match one of Business Central's nine table-type conventions
## Description
Business Central's Base Application follows nine recurring table types —
Master, Supplemental, Subsidiary, Ledger, Register, Journal, Document,
Document History, and Setup. Each type fixes a naming pattern, a
primary-key shape, and a set of associated pages. A new or extended table
whose design doesn't match the conventions of its own type is either
misclassified or built inconsistently with the rest of the application,
and should be flagged in review even if it compiles. Before assigning a
primary key or naming a new table, first identify which of the nine types
it is — that answer fixes almost every other design decision.
## Best Practice
Match the table's design to its type:
1. **Master** (Customer, Item) — one record is the subject; primary key
`Code[20]` named `No.`; description field in `DataCaptionFields`; Card +
List (+ Statistics) pages.
2. **Supplemental** (Currency, Language) — used across functional areas;
primary key `Code[10]` named `Code`; one List page, plural name, set as
`LookupPageID`.
3. **Subsidiary** (Item Vendor) — subsidiary to a Master/Supplemental
table; primary key is the parent key field(s), optionally + `Line No.`;
page shape depends on whether the table carries its own identity: a
pure parent-join table (Item Vendor) typically gets a plain List page
filtered by the calling page, while a subsidiary table that supplements
a master record with its own identity — parent key + own code, e.g.
Ship-to Address, Customer/Vendor Bank Account — commonly gets a
List+Card pair instead, for direct editing of that record.
4. **Ledger** (Cust. Ledger Entry) — transactional record of a functional
area; primary key `Integer` `Entry No.`, always auto-generated by
posting, never user-editable, no free add/delete; List page as
`LookupPageID`/`DrillDownPageID`.
5. **Register** (G/L Register) — table of contents for its Ledger, one row
per posting run; primary key `Integer` `No.`, auto-generated; carries
`From Entry No.`/`To Entry No.`; List page with a link to the Ledger.
6. **Journal** (Resource Journal Line) — where users enter data before
posting to a Ledger; primary key Template + Batch + `Integer` `Line No.`;
Worksheet page with `AutoSplitKey`, a Posting action, and a link to the
Ledger.
7. **Document** (Sales Header/Line) — posts to Ledgers via Journals, not
directly; Header primary key `Code[20]` `No.` (or + `Option Document
Type`); Line primary key = Header key renamed `<Document> No.` +
`Integer Line No.`; Document/Card page with a Posting action and a lines
subpage.
8. **Document History** (Posted Sales Invoice Header/Line) — posted copy of
a Document table, created during posting; mirrors the source table's
fields; never user-editable; same page shape but the Line-equivalent is
a List page, not a Worksheet.
9. **Setup** (General Ledger Setup) — exactly one record for a functional
area; primary key `Code[10]` named `Primary Key`, always blank; one page
with the key field hidden, whose `OnOpenPage` creates the singleton the
first time it's opened (`Reset()` → `Get()` → if not found, `Init()` →
`Insert()`) rather than assuming the record pre-exists.
A table named "Setup" that holds more than one record follows Subsidiary
rules instead — the name alone is not proof of type. When a table's
identity can't be resolved from its definition alone (e.g. a "Setup"-named
table with a real business-field key and no page), say so explicitly
rather than forcing a classification; settling it requires checking actual
row cardinality or call sites, not just the object definition.
These nine types cover Business Central's *business-record* tables — they
are not an exhaustive catalogue of every legitimate table shape. A
temporary/buffer table, a work queue, a log or telemetry table, a
cross-reference/mapping table with no business meaning of its own, or a
staging/working table used only inside one process is not required to fit
any of the nine, and forcing one into the nearest-looking type (usually
Ledger, because it has an `Integer` key, or Subsidiary, because it has a
composite key) produces a harmful redesign recommendation for a table that
was never meant to carry that type's guarantees. Apply this rule only when
the table's name, fields, or usage genuinely establish it as one of the
nine business-record types; when nothing points that way, this rule simply
does not apply — that is not the same as an unresolved classification.
See sample: [`table-design-must-match-bc-table-type-conventions.good.al`](table-design-must-match-bc-table-type-conventions.good.al).
## Anti Pattern
A table that mixes conventions from two types — for example, a "Ledger"
table with a user-editable primary key that lets users freely insert or
delete rows — is not "flexible", it is either misclassified or has skipped
a design step. A Ledger table's `Entry No.` must come only from the
posting routine; exposing it as an editable field breaks the type's core
guarantee that entries are an immutable, sequential audit trail.
See sample: [`table-design-must-match-bc-table-type-conventions.bad.al`](table-design-must-match-bc-table-type-conventions.bad.al).

View file

@ -0,0 +1,12 @@
codeunit 50100 "Tax Posting Helper"
{
procedure GetTaxAccount(var Setup: Record "Sales & Receivables Setup"): Code[20]
var
DefaultTaxAccountTxt: Label 'DEFAULT-TAX';
begin
Setup.Get();
if Setup."Tax Account No." <> '' then
exit(Setup."Tax Account No.");
exit(DefaultTaxAccountTxt);
end;
}

View file

@ -0,0 +1,9 @@
codeunit 50100 "Tax Posting Helper"
{
procedure GetTaxAccount(var Setup: Record "Sales & Receivables Setup"): Code[20]
begin
Setup.Get();
Setup.TestField("Tax Account No.");
exit(Setup."Tax Account No.");
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: data-modeling
keywords: [testfield, setup-table, configuration, mandatory-field, silent-fallback, correctness]
technologies: [al]
countries: [w1]
application-area: [all]
---
# TestField a Setup-Table Value Before Using It in a Correctness-Critical Branch
> Contributions welcome — open a PR to refine or extend this article.
## Description
A procedure that reads a field from a setup or configuration table inside a branch where the surrounding logic has already decided that value is required needs to guard against it being blank. A common anti-pattern silently treats "blank" as "feature not wanted": it checks the field for emptiness and falls through to a default instead of raising an error. `Get()` succeeding on the setup record only proves the record exists, not that the specific field was ever configured, so the fallback path makes a missing configuration indistinguishable from a deliberate one — and the wrong outcome, especially in financial, tax, or compliance postings, surfaces silently rather than as a crash a tester would notice.
## Best Practice
Once a business rule has decided that a setup-table field's value is required for a branch to behave correctly, call `TestField` on it before use, even though a plain read would "work" by returning a blank or zero without erroring. Write a test that blanks the setup field and asserts the resulting error, so the guard itself is verified rather than merely present.
See sample: [`testfield-required-setup-field.good.al`](testfield-required-setup-field.good.al).
## Anti Pattern
Reading a required setup-table field behind a presence check that falls through to a default value instead of erroring. This looks defensive because it never crashes, but it converts "administrator forgot to configure this" into "system silently did something else" — worse than a hard failure, because nobody is told anything went wrong.
See sample: [`testfield-required-setup-field.bad.al`](testfield-required-setup-field.bad.al).

View file

@ -0,0 +1,11 @@
// Both fields guarded the same way, out of habit rather than analysis.
if Customer.Get(SalesHeader."Sell-to Customer No.") then
CustomerHomePage := Customer."Home Page"; // low blast radius - fine
// but the same pattern, unexamined, was also applied here:
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then
VATBusPostingGroup := SalesHeader."VAT Bus. Posting Group"
else
VATBusPostingGroup := '';
// High blast radius: silently wrong VAT posting group reaches posting
// with no error, no TestField, and no reviewer in the loop.

View file

@ -0,0 +1,14 @@
// Low blast radius: guard, with an explicit chosen fallback.
if Customer.Get(SalesHeader."Sell-to Customer No.") then
CustomerHomePage := Customer."Home Page"
else
CustomerHomePage := '';
// Blank is an acceptable, deliberately-considered default here - the field
// is purely a display convenience and a reviewer sees it before the document ships.
// It is assigned explicitly, though, not left to whatever the variable
// happened to hold before this lookup ran.
// High blast radius: let it fail loud, because this feeds posted VAT.
SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo);
SalesHeader.TestField("VAT Bus. Posting Group");
VATBusPostingGroup := SalesHeader."VAT Bus. Posting Group";

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: error-handling
keywords: [defensive-programming, offensive-programming, fail-fast, blast-radius, guarded-lookup]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Match defensive vs. offensive error handling to the blast radius of being wrong
## Description
Whether code should guard gracefully (defensive) or fail loudly (offensive/fail-fast) is not a matter of habit or a blanket house style — it depends on what happens downstream if the guarded condition is silently defaulted or skipped. Treating every missing value the same way, defensively or offensively, is itself the anti-pattern: uniform defensiveness hides the failures that matter most, while uniform fail-fast turns ordinary, expected absence into unnecessary crashes. Two fields can look structurally identical — both read from a related record, both potentially missing — and still deserve opposite treatment depending on what they feed.
## Best Practice
Trace what a silently-defaulted or skipped value actually reaches before deciding how to guard it. If it reaches a posted ledger amount, a tax/VAT calculation, a quantity or price actually used in a transaction, or a legally/compliance-facing output, code offensively: let the lookup fail loud (`TestField`, an unguarded `Get()` expected to always succeed, or an explicit `Error`) so a human sees the problem before anything posts. If it is cosmetic, informational, or easily corrected after the fact (a display field, an optional UI enhancement, a report not yet run), code defensively — but the fallback must be an explicit, deliberately-chosen, named business value, never a blank or zero that is merely the datatype default. When genuinely unsure which category a field falls into, that is a question to resolve explicitly with whoever owns the requirement, not a coin flip.
See sample: [`defensive-vs-offensive-code-must-match-blast-radius.good.al`](defensive-vs-offensive-code-must-match-blast-radius.good.al).
## Anti Pattern
Guarding two fields the same way purely out of habit, without analyzing what each one feeds. A low-blast-radius field, such as a customer's home page URL shown only for convenience on a printed document, and a high-blast-radius field, such as the VAT posting group that determines VAT actually applied to a posted transaction, are both wrapped in the same `if Header.Get(...) then ... else` pattern with a blank/zero fallback — leaving the posting-critical field free to post with a silently wrong value. A VAT registration number is not a safe stand-in for the low-risk side of this example: it is legally relevant, often validated, and can feed external VAT services or mandated document output, so it belongs on the offensive/fail-fast side alongside the posting group, not next to it as the "safe" contrast.
See sample: [`defensive-vs-offensive-code-must-match-blast-radius.bad.al`](defensive-vs-offensive-code-must-match-blast-radius.bad.al).

View file

@ -0,0 +1,24 @@
codeunit 50101 "Sample Web Service Caller"
{
procedure CallExternalService()
var
ErrorLogEntry: Record "Sample Error Log";
begin
// BUG: the log write happens inside the same transaction as the
// risky call, using the same Record instance as the caller.
if not TryCallService() then begin
ErrorLogEntry.Init();
ErrorLogEntry."Error Message" := CopyStr(GetLastErrorText(), 1, 250);
ErrorLogEntry.Insert();
Error(GetLastErrorText());
// Error() above rolls back this transaction - including the
// Insert() just made. The failure is never actually logged.
end;
end;
[TryFunction]
local procedure TryCallService()
begin
// ... external call that may fail ...
end;
}

View file

@ -0,0 +1,45 @@
table 50100 "Sample Error Log Buffer"
{
TableType = Temporary;
fields
{
field(1; "Call Duration (ms)"; Integer) { }
field(2; "Error Message"; Text[250]) { }
}
}
codeunit 50100 "Sample Error Log Writer"
{
// TableNo makes OnRun receive the Record that Session.StartSession
// passes to the new session. This is the only data channel into that
// session — there is no shared memory with the caller's instance.
TableNo = "Sample Error Log Buffer";
trigger OnRun()
var
ErrorLogEntry: Record "Sample Error Log";
begin
ErrorLogEntry.Init();
ErrorLogEntry."Call Duration (ms)" := Rec."Call Duration (ms)";
ErrorLogEntry."Error Message" :=
CopyStr(Rec."Error Message", 1, MaxStrLen(ErrorLogEntry."Error Message"));
ErrorLogEntry.Insert(true);
Commit();
end;
}
// Caller side: populate the buffer record, then hand it to StartSession.
// The insert-and-commit above happens inside the started session, so it
// survives even if the caller's own transaction rolls back afterward.
codeunit 50101 "Sample Error Log Caller Excerpt"
{
procedure LogFailure(Duration: Integer; ErrorText: Text)
var
LogBuffer: Record "Sample Error Log Buffer" temporary;
SessionId: Integer;
begin
LogBuffer."Call Duration (ms)" := Duration;
LogBuffer."Error Message" := CopyStr(ErrorText, 1, MaxStrLen(LogBuffer."Error Message"));
Session.StartSession(SessionId, Codeunit::"Sample Error Log Writer", CompanyName, LogBuffer);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: error-handling
keywords: [logging, rollback, session, transaction, isolated-session, telemetry]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Log writes that must capture failures must survive transaction rollback
## Description
Inserting a log record inside the same transaction as the operation it logs looks correct until the operation errors: the transaction rolls back and takes the log entry with it. The result is a log that faithfully records every success and silently loses exactly the failures it exists to capture. This is a common blind spot in error/duration logging around web-service calls, background jobs, and other operations expected to fail sometimes.
## Best Practice
Write any log whose purpose includes capturing failures from a transaction that is independent of the operation being logged: start an isolated session (`Session.StartSession` on a `TableNo`-scoped codeunit that only inserts the log record and commits) so the entry persists regardless of what happens to the caller's transaction. `StartSession`'s only channel for getting data into that new session is its optional `Record` parameter, delivered to the target codeunit's `OnRun` trigger — a separate session gets a fresh instantiation of the codeunit, so calling a setter procedure on a local object variable before starting the session does not populate anything in the new session's instance. Capture duration and other telemetry values in the caller, place them into the `Record` passed to `StartSession`, and do the insert-and-commit entirely inside that session's own `OnRun`. Logs that only record successful, committed work can safely stay in the main transaction; this pattern targets error and diagnostic logs specifically.
See sample: [`log-writes-must-survive-rollback.good.al`](log-writes-must-survive-rollback.good.al).
## Anti Pattern
Inserting the error-log record in the same transaction as the risky operation, so a rollback deletes the very entry meant to explain the failure. Adding a stray `Commit` before the risky call is not a fix either — it breaks the caller's atomicity and can violate posting-routine rules. Swallowing the error to keep the log alive (running a codeunit without checking or re-raising its result) is equally wrong: the log must observe the failure, not suppress it.
See sample: [`log-writes-must-survive-rollback.bad.al`](log-writes-must-survive-rollback.bad.al).

View file

@ -0,0 +1,18 @@
codeunit 50258 "Perf Sample RunModalInTxn Bad"
{
procedure ChangeShippingAgent(DocNo: Code[20])
var
SalesHeader: Record "Sales Header";
ShippingAgent: Record "Shipping Agent";
begin
SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo);
SalesHeader."Shipment Date" := WorkDate();
SalesHeader.Modify(true); // a write transaction is now open
// Runtime error: RunModal is not allowed in write transactions.
if Page.RunModal(Page::"Shipping Agents", ShippingAgent) = Action::LookupOK then begin
SalesHeader.Validate("Shipping Agent Code", ShippingAgent.Code);
SalesHeader.Modify(true);
end;
end;
}

View file

@ -0,0 +1,18 @@
codeunit 50259 "Perf Sample RunModalInTxn Good"
{
procedure ChangeShippingAgent(DocNo: Code[20])
var
SalesHeader: Record "Sales Header";
ShippingAgent: Record "Shipping Agent";
begin
// User interaction first, while no write transaction is open.
if Page.RunModal(Page::"Shipping Agents", ShippingAgent) <> Action::LookupOK then
exit;
// All writes after the choice is made; the transaction ends with the trigger.
SalesHeader.Get(SalesHeader."Document Type"::Order, DocNo);
SalesHeader."Shipment Date" := WorkDate();
SalesHeader.Validate("Shipping Agent Code", ShippingAgent.Code);
SalesHeader.Modify(true);
end;
}

View file

@ -0,0 +1,46 @@
---
bc-version: [all]
domain: performance
keywords: [limited-during-write-transactions, write-transaction, runmodal, report-run, page-runmodal, report-runmodal, xmlport-run, codeunit-run, commit, requestpage]
technologies: [al]
countries: [w1]
application-area: [all]
---
# AL methods limited during write transactions: commit before RunModal and Codeunit.Run
## Description
Once AL code has written to the database in the current transaction — an `Insert`, `Modify`, or `Delete` with no `Commit` since — the platform restricts four methods until that transaction is committed. The exact conditions, as enforced:
- `Page.RunModal` — not allowed in a write transaction, under any circumstances.
- `Report.RunModal` and `Report.Run` — both allowed only if the request page is suppressed: `Report.RunModal(ReportId, false)` / `Report.Run(ReportId, false)` (the second argument is `RequestWindow` in both), or `UseRequestPage(false)` on a report instance. With a request page shown, either fails identically — `Run` and `RunModal` differ only in whether the report instance is cleared afterward, not in this guard.
- `Xmlport.Run` — same rule when it would show a request page: allowed only if the request page is suppressed, `Xmlport.Run(XmlPortId, false)` (the second argument is `RequestWindow`) or the `UseRequestPage = false;` object property. With a request page shown it fails. There is no `XmlPort.RunModal` method — see the note on the platform's error text below.
- `Codeunit.Run` — allowed only if its Boolean return value is not used. `OK := Codeunit.Run()` and `if Codeunit.Run() then` fail, because that form commits — see `codeunit-run-requires-prior-commit-inside-transaction.md`.
This is a runtime error, not a compiler diagnostic: the code builds, and the first execution that reaches the call with an open write transaction dies. The guard keys on transaction state alone, not on any relation between what was written and what is opened: an `Item.Insert()` followed by `Page.RunModal(Page::"Customer Card")` — an unrelated table — fails on the `RunModal` line, and because the error stops the transaction, the insert rolls back with it.
The platform's message (Business Central 26, reproduced 2026-09-07) reads: "The following AL methods are limited during write transactions because one or more tables will be locked: Form.RunModal, Codeunit.Run, Report.RunModal, XmlPort.RunModal. Form.RunModal is not allowed in write transactions. Codeunit.Run is allowed in write transactions only if the return value is not used. For example, 'OK := Codeunit.Run()' is not allowed. Report.RunModal is allowed in write transactions only if 'RequestForm = false'. For example, 'Report.RunModal(...,false)' is allowed. XmlPort.RunModal is allowed in write transactions only if 'RequestForm = false'. For example, 'XmlPort.RunModal(...,false)' is allowed. Use the commit method to save the changes before this call, or structure the code differently." The message still uses the legacy names `Form.RunModal` and `RequestForm` even though the AL method is `Page.RunModal` and the report parameter is `RequestWindow`; older versions said "C/AL functions" instead of "AL methods" (microsoft/AL#5452, 2019). The message also names `XmlPort.RunModal`, which is legacy phrasing too — there is no such AL method; the actual API the guard applies to is `Xmlport.Run`, and the message's `RequestForm = false` condition corresponds to `Xmlport.Run`'s `RequestWindow` argument or the `UseRequestPage` property. The behavior is unchanged across versions.
The reason is the same one behind `avoid-user-prompts-inside-transactions.md`: a modal object waits for the user, and the platform will not let a write transaction — and every lock it holds — sit open for as long as that takes. The difference is enforcement. `Confirm` and `StrMenu` are *allowed* inside a write transaction and silently hold the locks; `RunModal` is *refused*. Both point at the same design fix.
## Best Practice
Sequence the work so the modal interaction happens before the write phase: run the lookup or dialog page first, then perform the writes the user's choice requires, and let the transaction end. When a modal object genuinely must follow a write, `Commit()` first — but only when the state written so far is complete and safe to persist on its own, because that Commit is a real transaction boundary, not a formality. Microsoft's own Base Application follows exactly this pattern where the preceding state is final (`ActivityLog.Table.al` commits the log entry before `Page.RunModal(Page::"Activity Log", Rec)`; `DocumentSendingProfile.Table.al` commits before `Page.RunModal(Page::"Select Sending Options", …)`). For a report, suppressing the request page — `Report.UseRequestPage(false)` on a report instance, or `false` as the `RequestWindow` argument to `Run`/`RunModal` — is a legitimate way to run it inside a write transaction when no user input is needed. For an XMLport, the equivalent is `false` as the `RequestWindow` argument to `Xmlport.Run`, or the `UseRequestPage = false;` object property; XMLports have no instance `UseRequestPage` method. `Database.IsInWriteTransaction()` (runtime 11.0+) lets library code that cannot control its caller detect the state, with the same caveat as the Codeunit.Run article: branching production flow on it usually signals unclear transaction ownership.
See sample: [`al-methods-limited-during-write-transactions.good.al`](al-methods-limited-during-write-transactions.good.al).
## Anti Pattern
Writing to the database and then calling `Page.RunModal` (or a report/XMLport with its request page) in the same trigger — the first production run hits the runtime error. The reflexive fixes are worse than the error: dropping in `Commit()` to silence it persists a half-finished state that can no longer roll back with the rest of the operation, and swapping the page for a `Confirm` or `StrMenu` to "avoid the error" trades a loud failure for the silent lock-holding that `avoid-user-prompts-inside-transactions.md` warns about.
See sample: [`al-methods-limited-during-write-transactions.bad.al`](al-methods-limited-during-write-transactions.bad.al).
## Source
- Microsoft Learn, `Codeunit.Run` transaction semantics ("If you're already in a transaction you must commit first before calling Codeunit.Run"): https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/codeunit/codeunit-run-method
- Microsoft Learn, `Xmlport.Run(Integer [, Boolean] [, Boolean] [, var Record])` (the `RequestWindow` argument; confirms the XMLport data type has no `RunModal` method, static or instance): https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/xmlport/xmlport-run-method
- Microsoft Learn, `UseRequestPage` property ("Applies to: Xml Port, Report" — an object property, not an XMLport instance method; the instance method of the same name exists only on `Report`): https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/properties/devenv-userequestpage-property
- microsoft/AL issue #5452 (verbatim English error text, reproduced on "any current version of Business Central", 2019-11-13): https://github.com/microsoft/AL/issues/5452
- Reproduced 2026-09-07 on Business Central 26 (runtime error dialog, English and Danish clients): a page `OnAction` doing `Item.Insert()` then `Page.RunModal(Page::"Customer Card")` fails with the text quoted in the Description, the AL call stack pointing at the `RunModal` line, and the dialog stating that the transaction was stopped.
- Microsoft Base Application (BCApps, W1): the `Commit(); … Page.RunModal(…)` pattern in `Modules/System/Logging/ActivityLog.Table.al`, `Foundation/Reporting/DocumentSendingProfile.Table.al`, `Bank/Setup/PaymentServiceSetup.Table.al`, and others; BCApps test suites annotate the same guard as "COMMIT is required for Write Transaction Error" (`Tests/General Journal/ERMTestMultipleGenJnlLines.Codeunit.al`).

View file

@ -1,7 +1,7 @@
---
bc-version: [all]
domain: performance
keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table]
keywords: [n-plus-one, get, findfirst, loop, inner-lookup, large-table, item-get, query]
technologies: [al]
countries: [w1]
application-area: [all]

View file

@ -11,7 +11,7 @@ application-area: [all]
## Description
A `Confirm`, `StrMenu`, modal page, or other user prompt issued from inside a write transaction stalls the transaction — and therefore every lock it holds — until the user responds. Per the upstream guidance, "Avoid user interactions (Confirm, StrMenu) inside transactions — they hold locks while waiting for user input." The wait is bounded only by the user; meanwhile other sessions block on whatever this transaction has acquired.
A `Confirm`, `StrMenu`, or other user prompt issued from inside a write transaction stalls the transaction — and therefore every lock it holds — until the user responds. Per the upstream guidance, "Avoid user interactions (Confirm, StrMenu) inside transactions — they hold locks while waiting for user input." The wait is bounded only by the user; meanwhile other sessions block on whatever this transaction has acquired.
## Best Practice

View file

@ -0,0 +1,16 @@
report 50105 "Sales Quote Confirmation"
{
UsageCategory = Documents;
ApplicationArea = All;
rendering
{
layout(RDLC)
{
Type = RDLC;
LayoutFile = './Layouts/SalesQuoteConfirmation.rdl';
}
}
DefaultRenderingLayout = RDLC;
}

View file

@ -0,0 +1,17 @@
report 50105 "Sales Quote Confirmation"
{
UsageCategory = Documents;
ApplicationArea = All;
rendering
{
layout(Word)
{
Type = Word;
LayoutFile = './Layouts/SalesQuoteConfirmation.docx';
Caption = 'Word Layout';
}
}
DefaultRenderingLayout = Word;
}

View file

@ -0,0 +1,34 @@
---
bc-version: [all]
domain: performance
keywords: [report-layout, word-layout, rdlc, document-report, sandbox-app-domain, rendering]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Document reports should default to a Word layout, not RDLC
> Contributions welcome — open a PR to refine or extend this article.
## Description
For a document report — an invoice, statement, order confirmation, or any report meant to be printed, emailed, or exported as a single-record document — Microsoft's own guidance recommends a `Word` rendering layout over `RDLC`: "RDL layouts can result in slower performance with document reports, regarding actions that are related to the user interface (for example, like sending emails) compared to Word layouts," and "we recommend that you design Word layouts instead of RDL" for this report shape (see Sources). This is a documented recommendation, not a universal guarantee that Word outperforms RDLC for every workload, and it does not apply to every report: tabular/list reports with heavy aggregation or calculated columns are still often a better fit for RDLC or Excel.
## Best Practice
For document reports, prefer a `Word` layout (`DefaultRenderingLayout = Word`) over RDLC unless specific layout requirements favor RDLC. Reserve RDLC (or Excel) for reports that represent a data listing rather than a document.
## Sources
- [Report Design Overview](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-report-design-overview)
- [Creating an RDL layout report](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-report-performance)
- [Troubleshooting reports / Report performance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-reports-troubleshooting)
See sample: [`document-report-word-layout.good.al`](document-report-word-layout.good.al).
## Anti Pattern
Defaulting a document report's layout to RDLC out of habit or because a template happened to use it. This inherits RDLC's sandboxed-app-domain performance cost with no benefit tied to the report's actual content or calculation needs.
See sample: [`document-report-word-layout.bad.al`](document-report-word-layout.bad.al).

View file

@ -0,0 +1,26 @@
codeunit 50181 "Scanner Receipt Import Bad"
{
procedure PostScannedReceipt(ItemNo: Code[20]; LocationCode: Code[10]; UnitOfMeasureCode: Code[10]; ScannedQuantity: Decimal; DocumentNo: Code[20])
var
ItemJournalLine: Record "Item Journal Line";
ItemJnlPostLine: Codeunit "Item Jnl.-Post Line";
begin
if ScannedQuantity <= 0 then
Error(PositiveQuantityErr);
ItemJournalLine.Init();
ItemJournalLine.Validate("Posting Date", WorkDate());
ItemJournalLine.Validate("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt.");
ItemJournalLine.Validate("Document No.", DocumentNo);
ItemJournalLine.Validate("Item No.", ItemNo);
ItemJournalLine.Validate("Location Code", LocationCode);
ItemJournalLine."Unit of Measure Code" := UnitOfMeasureCode;
ItemJournalLine.Quantity := ScannedQuantity;
ItemJournalLine."Quantity (Base)" := ScannedQuantity;
ItemJnlPostLine.RunWithCheck(ItemJournalLine);
end;
var
PositiveQuantityErr: Label 'The scanned quantity must be greater than zero.';
}

View file

@ -0,0 +1,25 @@
codeunit 50180 "Scanner Receipt Import Good"
{
procedure PostScannedReceipt(ItemNo: Code[20]; LocationCode: Code[10]; UnitOfMeasureCode: Code[10]; ScannedQuantity: Decimal; DocumentNo: Code[20])
var
ItemJournalLine: Record "Item Journal Line";
ItemJnlPostLine: Codeunit "Item Jnl.-Post Line";
begin
if ScannedQuantity <= 0 then
Error(PositiveQuantityErr);
ItemJournalLine.Init();
ItemJournalLine.Validate("Posting Date", WorkDate());
ItemJournalLine.Validate("Entry Type", ItemJournalLine."Entry Type"::"Positive Adjmt.");
ItemJournalLine.Validate("Document No.", DocumentNo);
ItemJournalLine.Validate("Item No.", ItemNo);
ItemJournalLine.Validate("Location Code", LocationCode);
ItemJournalLine.Validate("Unit of Measure Code", UnitOfMeasureCode);
ItemJournalLine.Validate(Quantity, ScannedQuantity);
ItemJnlPostLine.RunWithCheck(ItemJournalLine);
end;
var
PositiveQuantityErr: Label 'The scanned quantity must be greater than zero.';
}

View file

@ -0,0 +1,37 @@
---
bc-version: [all]
domain: scm
keywords: [quantity-base, qty-per-unit-of-measure, unit-of-measure-code, unit-of-measure-management, calcbaseqty, getqtyperunitofmeasure, qty-rounding-precision, item-journal-line]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Derive base quantities through the line's unit of measure
## Description
Inventory, item ledger entries, reservations, item tracking, and warehouse quantities are measured in the item's base unit of measure. Document and journal lines hold `Quantity` in the line's `"Unit of Measure Code"`, together with `"Qty. per Unit of Measure"` and base-unit fields such as `"Quantity (Base)"`. Ten boxes of twelve pieces are 120 base units, not 10. When a line's `Quantity` is validated, the table derives the base quantity through its `CalcBaseQty` procedure. That procedure calls `"Unit of Measure Management".CalcBaseQty` with the line's quantity rounding precision and raises an error when rounding would turn a non-zero quantity into a zero base quantity. Code that bypasses this conversion creates a line whose quantity and base quantity disagree, or makes a stock decision in the wrong unit.
## Best Practice
On a document or journal line, validate `"Unit of Measure Code"` before `Quantity`, and validate both. Validating the unit of measure sets `"Qty. per Unit of Measure"` from the item unit of measure; validating the quantity then fills the base fields with the correct rounding. Compare line quantities with inventory or availability in base units, for example `"Quantity (Base)"` or `"Outstanding Qty. (Base)"`.
Outside a line, get the factor with `"Unit of Measure Management".GetQtyPerUnitOfMeasure(Item, UnitOfMeasureCode)` and convert with its `CalcBaseQty` or `CalcQtyFromBase` procedures instead of multiplying by hand. Pass the item unit's quantity rounding precision where the available overload accepts it.
Reading these fields for display, reporting, or a temporary buffer that is never posted is not a conversion defect. Code that proves the line uses the base unit of measure (`"Qty. per Unit of Measure"` equal to 1) is also correct, but don't assume this from the item alone, because a line can use another unit.
See sample: [`derive-base-quantities-through-the-line-unit-of-measure.good.al`](derive-base-quantities-through-the-line-unit-of-measure.good.al).
## Anti Pattern
Assigning `Quantity` directly on an item journal, sales, purchase, or transfer line and then inserting, modifying, or posting it. Also assigning a base field such as `"Quantity (Base)" := Quantity`, multiplying by a hard-coded or separately looked-up factor without the line's rounding, or comparing a line's `Quantity` with `Item.Inventory` or another base-unit value. Detection signal: a direct `:=` to `Quantity`, `"Qty. per Unit of Measure"`, or a `(Base)` quantity field on a persisted or posted line, or a comparison between a non-base line quantity and an inventory quantity.
See sample: [`derive-base-quantities-through-the-line-unit-of-measure.bad.al`](derive-base-quantities-through-the-line-unit-of-measure.bad.al).
## References
- [Set up units of measure, including quantity rounding precision](https://learn.microsoft.com/en-us/dynamics365/business-central/inventory-how-setup-units-of-measure)
- [BCApps: Unit of Measure Management conversions](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Foundation/UOM/UnitofMeasureManagement.Codeunit.al)
- [BCApps: Item Journal Line quantity validation and CalcBaseQty](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Inventory/Journal/ItemJournalLine.Table.al)
- [BCApps: Sales Line quantity validation and CalcBaseQty](https://github.com/microsoft/BCApps/blob/4abbb8ff848cdcb4e1187fc7a3e2da0612dd0d2b/src/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al)

View file

@ -0,0 +1,11 @@
codeunit 50161 "Cancel External Quotes Bad"
{
procedure CancelQuote(ExternalDocumentNo: Text)
var
SalesHeader: Record "Sales Header";
begin
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
SalesHeader.SetFilter("External Document No.", ExternalDocumentNo);
SalesHeader.DeleteAll(true);
end;
}

View file

@ -0,0 +1,25 @@
codeunit 50160 "Cancel External Quotes Good"
{
procedure CancelQuote(ExternalDocumentNo: Code[35])
var
SalesHeader: Record "Sales Header";
begin
if ExternalDocumentNo = '' then
Error(MissingExternalDocumentNoErr);
SalesHeader.SetRange("Document Type", SalesHeader."Document Type"::Quote);
SalesHeader.SetRange("External Document No.", ExternalDocumentNo);
SalesHeader.DeleteAll(true);
end;
procedure CancelQuotes(ExternalDocumentNos: List of [Code[35]])
var
ExternalDocumentNo: Code[35];
begin
foreach ExternalDocumentNo in ExternalDocumentNos do
CancelQuote(ExternalDocumentNo);
end;
var
MissingExternalDocumentNoErr: Label 'The external document number is missing.';
}

View file

@ -0,0 +1,36 @@
---
bc-version: [all]
domain: security
keywords: [setfilter, setrange, filter-expression, filter-injection, external-input, wildcard, deleteall, modifyall]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not concatenate external text into a SetFilter expression
## Description
The `String` argument of `SetFilter` is a filter expression, not a value. Characters such as `..`, `|`, `&`, `<`, `>`, `=`, `*`, `?`, `@`, parentheses, and single quotes are operators. Text from a user, request page, API payload, file, or another system that is concatenated into that expression can therefore change which records match: `*` matches every value, `A|B` widens an exact lookup to two values, `10000..` becomes a range, and `J & V` becomes a conjunction or an invalid filter. `SetFilter` with an empty expression applies no filter at all. When the filtered record is then modified, deleted, exported, or used for a permission-relevant decision, the procedure acts on records the caller never identified.
## Best Practice
Treat an externally supplied identifier as a value. Use `SetRange(Field, Value)` for equality and `SetRange(Field, FromValue, ToValue)` for a typed range; neither parses operators. Reject an empty identifier explicitly when "no value" must not mean "every record". For several external values, apply `SetRange` once per value instead of joining them with `|`.
Use `SetFilter` when an operator is part of the procedure's own contract. Keep the operator in a constant expression and pass operands through replacement fields (`%1`, `%2`) of the field's data type, such as a `Date` or `Decimal` operand for `'>=%1'`. Do not rely on wrapping text in single quotes to neutralize it: according to the filter syntax, quotes protect `&`, `(`, `)`, `=`, and `|`, but `*` still acts as a wildcard inside `'J & V*'`.
Text that the user deliberately entered as a filter is supposed to be parsed. Do not report a request-page or `FilterPageBuilder` filter, a `GetFilters`/`GetView` round trip, a FlowFilter, or a field whose documented purpose is to hold a filter expression. Constant filter strings and operands produced by the extension's own code are also not external input.
See sample: [`do-not-concatenate-external-text-into-setfilter.good.al`](do-not-concatenate-external-text-into-setfilter.good.al).
## Anti Pattern
`Rec.SetFilter(Field, ExternalText)` or `Rec.SetFilter(Field, Prefix + ExternalText + Suffix)` where the text is meant to identify one record or a known list of records, with no validation that restricts it to a literal value. The finding is strongest when the resulting set is written by `Modify`, `ModifyAll`, `Delete`, or `DeleteAll`, or is returned to an external caller. Detection signal: a non-constant expression, concatenation, or `StrSubstNo` result passed as the `String` argument of `SetFilter`, where the operand comes from a parameter, page field, JSON/XML value, file line, or HTTP request.
See sample: [`do-not-concatenate-external-text-into-setfilter.bad.al`](do-not-concatenate-external-text-into-setfilter.bad.al).
## References
- [Record.SetFilter method, including the empty-filter remark](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-setfilter-method)
- [Filtering with SetRange and SetFilter](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-setcurrentkey-setrange-setfilter-getrangemin-and-getrangemax-methods)
- [Filter criteria, operators, and values that contain symbols](https://learn.microsoft.com/en-us/dynamics365/business-central/ui-enter-criteria-filters#filter-criteria-and-operators)

View file

@ -0,0 +1,12 @@
permissionset 50100 "Sample - Integration"
{
Access = Public;
Assignable = false;
Caption = 'Sample Integration';
Permissions =
tabledata "Sample Order" = RIMD;
// BUG: "Sample Order API" (PageType = API) and "Sample Order Query"
// (a published API query) have no "= X" entry anywhere in this app.
// Both endpoints are unreachable even though the table looks fully
// granted - nobody decided who may call them.
}

View file

@ -0,0 +1,10 @@
permissionset 50100 "Sample - Integration"
{
Access = Public;
Assignable = false;
Caption = 'Sample Integration';
Permissions =
tabledata "Sample Order" = RIMD,
page "Sample Order API" = X, // exposed API page: execute granted
query "Sample Order Query" = X; // exposed API query: execute granted
}

View file

@ -0,0 +1,32 @@
---
bc-version: [all]
domain: security
keywords: [permission-set, api-page, web-service, exposure, access-control]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Every exposed object must belong to a permission set
## Description
An object that is reachable from outside the app's own UI is only usable if it is also granted execute access through a permission set. Three distinct mechanisms make an object reachable this way, each with its own permission target:
- A page or query published through the **Web Services** configuration page, or a custom REST endpoint declared with `PageType = API` / `QueryType = API` — both need a `page "..." = X` / `query "..." = X` entry for that object.
- A codeunit published through **Web Services** exposes *every* public procedure on it as a SOAP operation automatically — there is no per-method attribute to add. SOAP web service support is deprecated and scheduled for removal; prefer publishing an API page/query for a new integration rather than a new codeunit web service. The permission target for an existing published codeunit is the codeunit itself: `codeunit "..." = X`.
- `[ServiceEnabled]` is a method-level attribute used on a *page* procedure to expose it as an OData v4 bound action (for example a `Post` action on an invoice page) — it does not apply to pages, queries, or codeunits as an object-level property, and it does not create its own permission target. The action is still a call into that page object, so the page's own `page "..." = X` entry is what governs it.
When such an object is left out of every permission set, it becomes both unusable (no caller, human or service, can reach it) and invisible in review: nobody deliberately decided who may call it. Exposure without a matching grant is not a safe default; it is an endpoint nobody is governing.
## Best Practice
Give every exposed object an explicit execute entry in a permission set shipped by the app: `page "..." = X` / `query "..." = X` for a published or API page/query (including one that exposes a `[ServiceEnabled]` bound action), and `codeunit "..." = X` for a codeunit published as a web service. Route sensitive endpoints into a dedicated, non-default admin permission set so reaching them requires a deliberate grant rather than being included by default. If an object should never be reachable from outside the app, remove the exposure itself (drop `PageType = API` / the Web Services registration) rather than leaving an orphaned endpoint with no permission-set membership.
See sample: [`exposed-objects-must-be-in-a-permission-set.good.al`](exposed-objects-must-be-in-a-permission-set.good.al).
## Anti Pattern
Granting access to the underlying table data while forgetting to grant execute access to the exposed page or query itself. The table looks fully covered by a permission set, but the API/service layer in front of it has no `= X` entry anywhere, so the endpoint silently fails for every caller even though the data permissions look complete.
See sample: [`exposed-objects-must-be-in-a-permission-set.bad.al`](exposed-objects-must-be-in-a-permission-set.bad.al).

View file

@ -1,4 +1,4 @@
permissionset 50203 "Sec Sample Report Runner"
{
Permissions = tabledata "G/L Entry" = ri;
Permissions = tabledata "G/L Entry" = r;
}

View file

@ -1,7 +1,7 @@
---
bc-version: [all]
domain: security
keywords: [permissionset, indirect-permissions, ri, ii, mi, di, code-mediated]
keywords: [permissionset, indirect-permissions, lowercase, code-mediated]
technologies: [al]
countries: [w1]
application-area: [all]
@ -15,8 +15,12 @@ In a `permissionset`, uppercase letters (`R`, `I`, `M`, `D`) grant **direct** pe
## Best Practice
Use indirect permissions (`ri`, `ii`, `mi`, `di`) when a role needs access to a sensitive table only through a specific codeunit or report — for example, a "Report Runner" role that reads `G/L Entry` only via published reports. Pair the indirect grant with the codeunit or report that mediates access; that object's own permissions (or InherentPermissions) supply the direct rights. Document why indirect permissions are required in the permission set or in the consuming object's comments. See sample: [`indirect-permissions-for-elevated-access.good.al`](indirect-permissions-for-elevated-access.good.al).
Use indirect permissions (the lowercase letters `r`, `i`, `m`, `d`) when a role needs access to a sensitive table only through a specific codeunit or report — for example, a "Report Runner" role that reads `G/L Entry` only via published reports, granted `tabledata "G/L Entry" = r`. Each letter is one permission: `ri` grants indirect read **and** indirect insert, so grant only the letters the role needs. Pair the indirect grant with the codeunit or report that mediates access; that object's own permissions (or InherentPermissions) supply the direct rights. Document why indirect permissions are required in the permission set or in the consuming object's comments. See sample: [`indirect-permissions-for-elevated-access.good.al`](indirect-permissions-for-elevated-access.good.al).
## Anti Pattern
Granting `RIMD` on a sensitive table when the role only needs to view it through a report — for example `tabledata "G/L Entry" = RIMD` on a "Report Runner" role. Users assigned that role can now query and modify ledger entries directly through any client that respects the permission, bypassing the report entirely. Reviewers should look for uppercase grants on system-of-record tables (G/L Entry, ledger entries, posted documents) where the consuming code path is clearly read-through-report or read-through-API. See sample: [`indirect-permissions-for-elevated-access.bad.al`](indirect-permissions-for-elevated-access.bad.al).
## References
[Permissions property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-permissions-property) and [Permissions on database objects](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-permissions-on-database-objects) define one letter per permission: `R`/`r` read, `I`/`i` insert, `M`/`m` modify, `D`/`d` delete, uppercase for direct and lowercase for indirect.

View file

@ -0,0 +1,24 @@
---
bc-version: [all]
domain: style
keywords: [build, output, alpackages, artifact-hygiene, outfolder, project-root]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Write AL Build Artifacts to an Intentional Output Location
> Contributions welcome — open a PR to refine or extend this article.
## Description
An AL project's compiled `.app` file can be written to the project root by default, and current tooling explicitly supports choosing a different destination instead — `ALTool`'s `--outfolder` option and the `al_build` agent tool's `outputPath` parameter both exist for this. The problem this rule addresses is not that root-level output is technically invalid; it is agents leaving generated `.app` files scattered through arbitrary source locations, or treating a compiled artefact as if it were part of the source tree (committing it, editing around it, referencing it as a dependency by hand).
## Best Practice
Write build artifacts to a deliberate, dedicated output location — configured via the build tool actually in use (e.g. `ALTool --outfolder`, or an explicit `outputPath` on the agent build tool) — and add that folder to `.gitignore`. Treat a compiled `.app` as a build artifact, never as a source file to commit or hand-edit around.
## Anti Pattern
Letting `.app` files accumulate in arbitrary or unversioned locations without a deliberate output path, or committing compiled artefacts into source control alongside the AL files that produced them.

View file

@ -0,0 +1,18 @@
codeunit 50101 "Credit Memo Routing"
{
procedure PostSalesLine(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line"; CustomerNo: Code[20]; Amount: Decimal)
begin
// Set the customer number
SalesHeader.Validate("Sell-to Customer No.", CustomerNo);
// Insert the line
SalesLine.Insert(true);
// Check if the amount is positive
if Amount > 0 then
// Post the entry
PostEntry(Amount);
end;
local procedure PostEntry(Amount: Decimal)
begin
end;
}

View file

@ -0,0 +1,20 @@
codeunit 50101 "Credit Memo Routing"
{
procedure PostSalesLine(var SalesHeader: Record "Sales Header"; var SalesLine: Record "Sales Line"; CustomerNo: Code[20]; Amount: Decimal)
begin
SalesHeader.Validate("Sell-to Customer No.", CustomerNo);
SalesLine.Insert(true);
// Negative amounts arrive from credit memos routed through this
// codeunit; PostEntry() rejects them, so they're filtered here.
if Amount < 0 then
exit;
if Amount > 0 then
PostEntry(Amount);
end;
local procedure PostEntry(Amount: Decimal)
begin
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: style
keywords: [comments, verbosity, self-documenting, restate, tutorial-style]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Comments must not restate what the code already shows
## Description
A comment above nearly every statement that just narrates what the statement already says (`// Validate the customer number` above `SalesHeader.Validate("Sell-to Customer No.", CustomerNo)`) adds noise without adding information. Production AL — the Base Application, mature partner codebases — is comment-sparse by comparison: identifiers do the explaining, and a comment appears only when the code alone can't carry the reason.
A comment earns its place only when it captures something the code cannot: a non-obvious business rule, a workaround for a specific platform limitation, or a constraint that would surprise the next reader. If removing the comment would leave the reader no worse off, the comment should not have been written.
This does not override required structural documentation — feature/scenario test tags and XML-doc summaries on public library procedures remain required where they apply; those are structural markers, not narrative comments.
## Best Practice
Let the code speak for itself; reserve comments for the reason a reader could not otherwise infer.
See sample: [`al-comments-must-not-restate-what-code-already-shows.good.al`](al-comments-must-not-restate-what-code-already-shows.good.al).
## Anti Pattern
A comment line before every statement, repeating in English what the statement's own identifiers already say.
See sample: [`al-comments-must-not-restate-what-code-already-shows.bad.al`](al-comments-must-not-restate-what-code-already-shows.bad.al).

View file

@ -0,0 +1,11 @@
codeunit 50100 "Sales Amount Calculator"
{
procedure BeregnTotalbeloeb(var Salgslinje: Record "Sales Line"): Decimal
var
Beloeb: Decimal;
begin
Salgslinje.CalcSums(Amount);
Beloeb := Salgslinje.Amount;
exit(Beloeb);
end;
}

View file

@ -0,0 +1,11 @@
codeunit 50100 "Sales Amount Calculator"
{
procedure CalculateTotalAmount(var SalesLine: Record "Sales Line"): Decimal
var
TotalAmount: Decimal;
begin
SalesLine.CalcSums(Amount);
TotalAmount := SalesLine.Amount;
exit(TotalAmount);
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: style
keywords: [identifiers, naming, english, captions, translation]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Write AL Identifiers in English Only
> Contributions welcome — open a PR to refine or extend this article.
## Description
All AL identifiers — variables, procedures, parameters, fields, object names, enum values, and label identifiers — must be written in English, regardless of the developer's native language. Translations are handled separately through captions, tooltips, and XLIFF files, never by writing Danish, German, or other non-English identifiers directly into AL source code. This is not a stylistic preference: the public `microsoft/BCApps` codebase, maintained by engineers of many nationalities across hundreds of thousands of lines of AL, uses exclusively English identifiers, with all localization handled via caption properties and XLIFF rather than by changing identifier names. On any multi-contributor codebase, non-English identifiers make the code unreadable to contributors who don't share that language.
## Best Practice
Write every identifier in English, and translate developer intent rather than transliterating it — when a requirement is described in another language, the resulting variable, procedure, and field names should still read as English. Captions and tooltips may carry target-language text in the source file, with locale translations managed through XLIFF.
See sample: [`al-identifiers-english.good.al`](al-identifiers-english.good.al).
## Anti Pattern
Using native-language identifiers such as a Danish variable or procedure name in AL source code, relying on the fact that the code still compiles and runs correctly. This makes the code unreadable to non-native-language contributors and mixes localization concerns into source that should stay language-neutral.
See sample: [`al-identifiers-english.bad.al`](al-identifiers-english.bad.al).

View file

@ -0,0 +1,22 @@
table 50100 "Sales Task"
{
fields
{
field(1; "No."; Code[20]) { }
field(10; Status; Option)
{
OptionMembers = Active,Blocked;
}
}
}
codeunit 50100 "Sales Task Check"
{
procedure IsOverdue(DueDate: Date): Integer
begin
// 0 = No, 1 = Yes
if DueDate < Today then
exit(1);
exit(0);
end;
}

View file

@ -0,0 +1,16 @@
table 50100 "Sales Task"
{
fields
{
field(1; "No."; Code[20]) { }
field(10; Blocked; Boolean) { }
}
}
codeunit 50100 "Sales Task Check"
{
procedure IsOverdue(DueDate: Date): Boolean
begin
exit(DueDate < Today);
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: style
keywords: [boolean, option, yes-no, magic-number, variable-typing, field-typing]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Binary yes/no choices must be typed as Boolean, not Option or Integer
> Contributions welcome — open a PR to refine or extend this article.
## Description
When a field or variable represents a genuine true/false state — yes/no, on/off, active/inactive, blocked/not blocked — it should be typed `Boolean`. Modeling that same predicate as an `Option`/`Enum` with two members, or as an `Integer` with two magic-number values, adds a layer of indirection a reader has to resolve before understanding the code. This is about semantics, not member count: a domain concept that currently has exactly two named alternatives — Inbound/Outbound, Debit/Credit, Buy/Sell — is not automatically a Boolean in disguise. An `Enum` can be the clearer model there, including when it needs to implement an interface, preserve an existing contract, or leave room for a future third value. The distinction is whether the domain is genuinely a stable predicate, not how many states it currently has.
## Best Practice
Type a field or variable as `Boolean` when the domain concept is inherently a true/false state. Do not replace a meaningful two-option domain model with a Boolean solely because it currently has two values.
See sample: [`binary-choice-must-be-boolean.good.al`](binary-choice-must-be-boolean.good.al).
## Anti Pattern
Modeling a yes/no choice as an `Option` with two members, or as an `Integer` with magic-number values, forces every caller to remember which value means what and leaves room for a meaningless third value.
See sample: [`binary-choice-must-be-boolean.bad.al`](binary-choice-must-be-boolean.bad.al).

View file

@ -0,0 +1,22 @@
table 50101 "Course"
{
fields
{
field(1; "No."; Code[20]) { }
// 1 = Beginner, 2..3 = Intermediate, 4+ = Advanced
field(10; Difficulty; Integer) { }
}
}
codeunit 50100 "Course Level Helper"
{
procedure LevelText(Difficulty: Integer): Text
begin
case Difficulty of
1:
exit('Beginner');
2, 3:
exit('Intermediate');
end;
end;
}

View file

@ -0,0 +1,17 @@
enum 50100 "Course Difficulty"
{
Extensible = true;
value(0; Beginner) { }
value(1; Intermediate) { }
value(2; Advanced) { }
}
table 50101 "Course"
{
fields
{
field(1; "No."; Code[20]) { }
field(10; Difficulty; Enum "Course Difficulty") { }
}
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: style
keywords: [enum, option, integer, magic-number, variable-typing, field-typing]
technologies: [al]
countries: [w1]
application-area: [all]
---
# A fixed set of named choices must use Enum, not a raw Integer
> Contributions welcome — open a PR to refine or extend this article.
## Description
When a variable or field represents a fixed set of named, mutually exclusive states — a difficulty level, a document type, a processing status — it should be typed as `Enum` (or `Option` when extending an object that still uses the legacy type). Representing that same state as a plain `Integer` and tracking the meaning of each value in a comment or in a developer's head is a magic-number anti-pattern: the compiler cannot catch an out-of-range value, and branches read as opaque numbers instead of names. This is about semantics, not member count, matching `binary-choice-must-be-boolean.md`'s own distinction: a domain concept that is genuinely a stable true/false predicate belongs in `Boolean` even if someone represents it as a two-value `Enum`, while a domain concept with exactly two current named states is not automatically a Boolean in disguise — it stays an `Enum` when the states are named alternatives rather than a yes/no flag, or when it needs to implement an interface, preserve an existing contract, or leave room for a future third value.
## Best Practice
Declare an `Enum` with named values and branch on the enum value, not a raw number.
See sample: [`fixed-choice-set-must-use-enum-not-integer.good.al`](fixed-choice-set-must-use-enum-not-integer.good.al).
## Anti Pattern
Using a plain `Integer` field with the meaning of each value tracked only in a comment pushes the documentation of the states into something the compiler cannot check and a future maintainer cannot rely on.
See sample: [`fixed-choice-set-must-use-enum-not-integer.bad.al`](fixed-choice-set-must-use-enum-not-integer.bad.al).

View file

@ -0,0 +1,12 @@
codeunit 50100 "Sales Task Notify"
{
procedure NotifyUpdate(Count: Integer; CustomerNo: Code[20])
begin
MESSAGE('%1 records updated.', Count);
IF NOT CONFIRM('Delete %1?', FALSE, CustomerNo) THEN
EXIT;
ERROR(STRSUBSTNO('%1 must not be blank.', CustomerNo));
end;
}

View file

@ -0,0 +1,12 @@
codeunit 50100 "Sales Task Notify"
{
procedure NotifyUpdate(Count: Integer; CustomerNo: Code[20])
begin
Message('%1 records updated.', Count);
if not Confirm('Delete %1?', false, CustomerNo) then
exit;
Error(StrSubstNo('%1 must not be blank.', CustomerNo));
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: style
keywords: [casing, intrinsic-function, built-in-function, message, error, confirm, strsubstno, legacy, c-al, pascalcase]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Intrinsic AL function calls use modern casing, not legacy ALL-CAPS
> Contributions welcome — open a PR to refine or extend this article.
## Description
AL is case-insensitive, so `MESSAGE(...)`, `ERROR(...)`, `CONFIRM(...)`, and `STRSUBSTNO(...)` compile and run identically to `Message(...)`, `Error(...)`, `Confirm(...)`, and `StrSubstNo(...)`. Modern AL — Microsoft's own current samples and current reference codebases — writes intrinsic/built-in function calls in the casing Microsoft assigns to the function's declared name, typically PascalCase. This is a codebase-convention claim, not a claim about what the VS Code formatter enforces: the formatter normalizes particular syntax but is not a mechanism for recasing every intrinsic function call, so do not cite formatter behavior as the reason to follow this convention. ALL-CAPS calls are a holdover from classic C/AL and signal code that has not been modernized, even though it compiles and runs correctly. Reserved keywords such as `if`, `begin`, and `for` are a separate, already-tooled concern; intrinsic function names are identifiers, not keywords, so that tooling does not catch ALL-CAPS intrinsic function calls.
## Best Practice
Call intrinsic functions in their modern, PascalCase form.
See sample: [`intrinsic-al-functions-must-use-modern-casing.good.al`](intrinsic-al-functions-must-use-modern-casing.good.al).
## Anti Pattern
ALL-CAPS intrinsic function calls trip no compiler error, but they are a reliable signal that a code block was copied from old C/AL material or outdated training content rather than written against current AL conventions.
See sample: [`intrinsic-al-functions-must-use-modern-casing.bad.al`](intrinsic-al-functions-must-use-modern-casing.bad.al).

View file

@ -0,0 +1,13 @@
namespace Contoso.Extensions;
using System.Performance; // guessed; never verified against the source file
codeunit 50100 "Tooling Extension"
{
procedure Run()
var
ToolingPage: Page "Some Tooling Page"; // guessed namespace; can still resolve against a stale or cached symbol package, then fail once checked against the object's current source or a freshly downloaded one
begin
ToolingPage.Run();
end;
}

View file

@ -0,0 +1,16 @@
// Verified by reading line 1 of the source file for "Some Tooling Page":
// namespace System.Tooling;
namespace Contoso.Extensions;
using System.Tooling;
codeunit 50100 "Tooling Extension"
{
procedure Run()
var
ToolingPage: Page "Some Tooling Page";
begin
ToolingPage.Run();
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: style
keywords: [namespace, verification, source-of-truth, using-statement]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Resolve a namespace from the referenced object's source or symbols, never by guessing
> Contributions welcome — open a PR to refine or extend this article.
## Description
Since Business Central 2024 release wave 1, Microsoft's own objects are organized under a deep `Microsoft.*` namespace tree that has been renamed and restructured repeatedly. When adding a `using` directive for an existing AL object (table, codeunit, page, enum, interface, etc.), guessing its namespace from the object's name, from an older codebase, or from general familiarity produces a statement that can look plausible and still resolve to the wrong object, or fail to resolve at all, once checked against the object's actual current namespace. The reliable sources are the object's own source file (its `namespace` declaration) or, for a dependency without accessible source, its AL symbol package — not the object's name or a remembered convention.
## Best Practice
When referencing an existing AL object, resolve its namespace from that object's actual source file or symbol definition — never infer or invent one from its name, functional area, or naming convention.
See sample: [`namespace-must-be-verified-from-source.good.al`](namespace-must-be-verified-from-source.good.al).
## Anti Pattern
Writing a `using` statement from memory, from an incomplete path, or from a plausible-looking guess. It can appear correct while actually resolving to the wrong object, or fail to resolve, once checked against stale or mismatched symbols, a different build configuration, or the object's actual current source — not because the compiler and the AL Language Server apply different namespace-resolution rules; they don't.
See sample: [`namespace-must-be-verified-from-source.bad.al`](namespace-must-be-verified-from-source.bad.al).

View file

@ -0,0 +1,49 @@
table 50101 "Sample Order Line"
{
fields
{
field(1; "Document No."; Code[20]) { }
field(2; "Line No."; Integer) { }
field(10; Quantity; Decimal) { }
field(11; "Unit Price"; Decimal) { }
field(12; "Line Amount"; Decimal) { }
}
keys
{
key(PK; "Document No.", "Line No.") { Clustered = true; }
}
}
page 50100 "Sample Order Line Card"
{
PageType = Card;
SourceTable = "Sample Order Line";
layout
{
area(content)
{
repeater(General)
{
field(quantity; Rec.Quantity) { }
field(unitPrice; Rec."Unit Price") { }
field(lineAmount; Rec."Line Amount") { }
}
}
}
actions
{
area(Processing)
{
action(Recalculate)
{
trigger OnAction()
begin
Rec."Line Amount" := Rec.Quantity * Rec."Unit Price";
Rec.Modify();
end;
}
}
}
}

View file

@ -0,0 +1,60 @@
table 50101 "Sample Order Line"
{
fields
{
field(1; "Document No."; Code[20]) { }
field(2; "Line No."; Integer) { }
field(10; Quantity; Decimal) { }
field(11; "Unit Price"; Decimal) { }
field(12; "Line Amount"; Decimal) { }
}
keys
{
key(PK; "Document No.", "Line No.") { Clustered = true; }
}
}
codeunit 50100 "Sample Order Line Management"
{
procedure RecalculateLine(var OrderLine: Record "Sample Order Line")
begin
OrderLine.Validate("Line Amount", OrderLine.Quantity * OrderLine."Unit Price");
OrderLine.Modify(true);
end;
}
page 50100 "Sample Order Line Card"
{
PageType = Card;
SourceTable = "Sample Order Line";
layout
{
area(content)
{
repeater(General)
{
field(quantity; Rec.Quantity) { }
field(unitPrice; Rec."Unit Price") { }
field(lineAmount; Rec."Line Amount") { }
}
}
}
actions
{
area(Processing)
{
action(Recalculate)
{
trigger OnAction()
begin
OrderLineMgt.RecalculateLine(Rec);
end;
}
}
}
var
OrderLineMgt: Codeunit "Sample Order Line Management";
}

View file

@ -0,0 +1,31 @@
---
bc-version: [all]
domain: style
keywords: [pages, business-logic, codeunit, separation-of-concerns, presentation-layer]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep business logic out of page objects
## Description
A page procedure that persists a business mutation directly (`Rec.Modify()` outside the standard record-bound save, or a cross-entry-point business rule implemented only in a page trigger) is an architecture violation even when it compiles: the rule only applies when a user opens that specific page, and silently doesn't run through any other entry point (API, batch job, another page). This is narrower than "no calculation may live on a page" — a presentation-specific calculation (formatting, a derived display value) is fine on the page that shows it, and a reusable data invariant commonly belongs on the table itself (a field's own validation/trigger), not forced into a codeunit merely to keep it off the page. The actual line is entry-point independence: a business operation or invariant that must hold regardless of which entry point touches the record belongs in a codeunit or the table, not solely in one page's trigger.
A narrow set of patterns are conventional rather than violations:
- A setup page reading and writing its own singleton setup record.
- A dedicated "Run Conversion" page invoking a conversion codeunit directly.
- The standard singleton-initialization idiom on `OnOpenPage` (`if not Rec.Get() then begin Rec.Init(); Rec.Insert(); end`) used by cue/activities pages to bootstrap their own presentation-state record — this is not business logic, it is the same pattern used throughout base-app cue pages.
## Best Practice
Delegate all business operations to a codeunit: the page owns presentation, the codeunit owns logic. A calculation or validation triggered from a page action should call a codeunit procedure rather than compute the result inline.
See sample: [`pages-must-not-contain-business-logic.good.al`](pages-must-not-contain-business-logic.good.al).
## Anti Pattern
A cross-entry-point business rule or persisted mutation implemented only in a page trigger — calling `Rec.Modify()` to save a computed business value from `OnValidate`/`OnAction`, or a validation that must hold regardless of caller, instead of routed through a codeunit or the table's own field validation. A presentation-only calculation or a table-owned field invariant is not an instance of this anti-pattern.
See sample: [`pages-must-not-contain-business-logic.bad.al`](pages-must-not-contain-business-logic.bad.al).

View file

@ -0,0 +1,48 @@
---
bc-version: [all]
domain: style
keywords: [folder-structure, feature-organization, source-layout, maintainability]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Organize AL source by business feature, not object type
## Description
Folder structure inside an AL app has no effect on compilation or runtime behavior — this is a repository-organization convention, not a platform requirement, and different projects reasonably choose differently. Grouping files by business feature or module (`src/Sales/Invoice/`, `src/NoSeries/`) rather than by AL object type (`src/Tables/`, `src/Pages/`, `src/Codeunits/`) keeps everything belonging to one feature physically together, which many teams find easier to navigate than jumping between object-type folders that share nothing but their AL object kind. Adopt this consistently on a project rather than mixing both schemes, but treat it as a team convention to apply deliberately, not a Microsoft-mandated structure.
Code genuinely shared across multiple features (utility codeunits, common interfaces, shared enums) belongs in a `Common` or `Shared` folder, not duplicated per feature and not left in a catch-all root.
## Best Practice
src/
├── NoSeries/
├── Sales/
│ ├── Invoice/
│ └── Order/
└── Common/
Each feature folder holds every object type it needs; shared code has one dedicated home.
## Anti Pattern
A repository that documents or has established feature-based organization
as its convention, but then mixes in object-type folders for new work
anyway:
src/
├── Sales/
│ └── Invoice/
├── Tables/ <- new objects land here instead of a feature folder
└── Codeunits/
The anti-pattern is inconsistency with the project's own chosen convention,
not the object-type scheme itself — a repository that deliberately and
consistently organizes by object type throughout is exercising the other
reasonable choice described above, not violating this rule. What actually
costs a reader time is a codebase where some features live under their own
folder and others are scattered across type folders, so finding everything
related to one feature means checking both schemes and reassembling it from
wherever each object happened to land.

View file

@ -0,0 +1,15 @@
codeunit 50100 "Customer Lookup"
{
procedure GetCustomerName(CustomerNo: Code[20]; var Name: Text[100])
var
Customer: Record Customer;
begin
if Customer.Get(CustomerNo) then
Name := Customer.Name;
end;
procedure Sample()
begin
GetCustomerName('10000', 'placeholder'); // compile error: literal is not addressable
end;
}

View file

@ -0,0 +1,17 @@
codeunit 50100 "Customer Lookup"
{
procedure GetCustomerName(CustomerNo: Code[20]; var Name: Text[100])
var
Customer: Record Customer;
begin
if Customer.Get(CustomerNo) then
Name := Customer.Name;
end;
procedure Sample()
var
CustName: Text[100];
begin
GetCustomerName('10000', CustName);
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: style
keywords: [var-parameter, by-reference, pass-by-reference, literal, constant, compile-error, procedure-call]
technologies: [al]
countries: [w1]
application-area: [all]
---
# A `var` parameter must be called with a variable, never a literal or expression
> Contributions welcome — open a PR to refine or extend this article.
## Description
When a procedure declares a parameter with `var`, that parameter is passed by reference: the callee writes back into the caller's own memory location. This means the argument at the call site must be an actual variable, something with an address. A string literal, a numeric constant, or a computed expression has no address to write back to, so passing one to a `var` parameter fails to compile. Before writing a call, check the callee's signature for `var` on each parameter position being supplied a literal or expression — if present, a variable declared in the caller's own scope must be used instead.
## Best Practice
Declare a variable in the caller's scope and pass it to the `var` parameter.
See sample: [`var-parameters-require-an-addressable-variable.good.al`](var-parameters-require-an-addressable-variable.good.al).
## Anti Pattern
Passing a literal or a computed expression to a `var` parameter position fails to compile, because neither has an address the callee can write back to.
See sample: [`var-parameters-require-an-addressable-variable.bad.al`](var-parameters-require-an-addressable-variable.bad.al).

View file

@ -23,4 +23,6 @@ See sample: [`asserterror-needs-expectederror-and-code.good.al`](asserterror-nee
`asserterror DoInvalid();` with nothing after it. The test asserts only that the call failed somehow; swap the validation for a different bug and the test still passes, certifying a guard that may no longer fire. A negative test that cannot tell one error from another verifies almost nothing.
Not an instance of this anti-pattern: a trailing `asserterror Error(SomeLabel)` used purely as an end-of-test rollback sentinel to undo a lazily-initialized shared fixture's scratch changes (see `commit-shared-test-fixture-inside-lazy-initialize.md`). That `Error` call exists to force a rollback, not to verify that a specific failure occurred — the sentinel's own text is not meant to be asserted against, and adding an `ExpectedError` there would just duplicate the label without checking anything the test doesn't already control.
See sample: [`asserterror-needs-expectederror-and-code.bad.al`](asserterror-needs-expectederror-and-code.bad.al).

View file

@ -0,0 +1,29 @@
// Only wraps Microsoft's own generic scenario — measures BC, not this extension
codeunit 50101 "BCPT Create Sales Order" implements "BCPT Test Param. Provider"
{
SingleInstance = true;
trigger OnRun()
begin
CreateStandardSalesOrder(GlobalBCPTTestContext);
end;
var
GlobalBCPTTestContext: Codeunit "BCPT Test Context";
local procedure CreateStandardSalesOrder(var BCPTTestContext: Codeunit "BCPT Test Context")
begin
BCPTTestContext.StartScenario('Create Sales Order With N Lines');
// ... standard sales order creation, no reference to the extension's own logic
BCPTTestContext.EndScenario('Create Sales Order With N Lines');
end;
procedure GetDefaultParameters(): Text[1000]
begin
exit('');
end;
procedure ValidateParameters(Parameters: Text[1000])
begin
end;
}

View file

@ -0,0 +1,73 @@
codeunit 50100 "BCPT Create Service Request" implements "BCPT Test Param. Provider"
{
SingleInstance = true;
trigger OnRun()
begin
if not IsInitialized then begin
InitTest();
IsInitialized := true;
end;
CreateServiceRequest(GlobalBCPTTestContext);
end;
var
GlobalBCPTTestContext: Codeunit "BCPT Test Context";
CustomerNo: Code[20];
IsInitialized: Boolean;
local procedure InitTest()
var
Customer: Record Customer;
begin
// Do not assume a customer already exists: a BCPT run may target an
// otherwise-empty environment. Create one if none is found instead
// of failing on FindFirst().
if not Customer.FindFirst() then begin
Customer.Init();
Customer."No." := GenerateUniqueCode(MaxStrLen(Customer."No."));
Customer.Insert(true);
end;
CustomerNo := Customer."No.";
end;
local procedure GenerateUniqueCode(Length: Integer): Code[20]
begin
// A GUID-derived code, not a session-local counter: it stays unique
// across concurrent BCPT sessions and repeated runs against the
// same environment, which an in-memory counter reset per session
// cannot guarantee.
exit(CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, Length));
end;
local procedure CreateServiceRequest(var BCPTTestContext: Codeunit "BCPT Test Context")
var
ServiceRequestHeader: Record "Service Request Header";
ServiceRequestLine: Record "Service Request Line";
begin
BCPTTestContext.StartScenario('Create Service Request Header');
ServiceRequestHeader.Init();
ServiceRequestHeader."No." := GenerateUniqueCode(MaxStrLen(ServiceRequestHeader."No."));
ServiceRequestHeader.Validate("Customer No.", CustomerNo);
ServiceRequestHeader.Insert(true);
BCPTTestContext.EndScenario('Create Service Request Header');
BCPTTestContext.UserWait();
BCPTTestContext.StartScenario('Add Service Request Line');
ServiceRequestLine.Init();
ServiceRequestLine."Document No." := ServiceRequestHeader."No.";
ServiceRequestLine."Line No." := 10000;
ServiceRequestLine.Description := 'Performance test line';
ServiceRequestLine.Insert(true);
BCPTTestContext.EndScenario('Add Service Request Line');
end;
procedure GetDefaultParameters(): Text[1000]
begin
exit('');
end;
procedure ValidateParameters(Parameters: Text[1000])
begin
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [bcpt, performance-test, scenarios, app-specific, regression]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Include app-specific scenarios in a PerformanceTest app's BCPT suite
## Description
A PerformanceTest app that ships with only the generic Microsoft BCPT samples (creating sales orders, purchase orders, posting item journals) measures Business Central's own baseline performance, not the extension it was built to test. Those samples are starting points, not coverage. Without a scenario that exercises the extension's own business flow — its own codeunits, its own FlowFields, its own page rendering — a performance regression introduced by the extension has no test that would ever detect it.
## Best Practice
For every major business flow the extension adds, create a matching `BCPT*` scenario codeunit implementing `"BCPT Test Param. Provider"`, building its own test data in a local `InitTest()` procedure rather than depending on hardcoded records. Beyond that shared shape, the interface details are context-dependent, not fixed requirements: most of Microsoft's own shipped BCPT samples declare `SingleInstance = true`, but `codeunit "BCPT Create Customer"` does not, relying instead on `OnRun` calling `InitTest()` unconditionally every run. Likewise, wrapping the operation under test in `BCPTTestContext.StartScenario()` / `EndScenario()` is a real, available pattern for splitting one codeunit's run into several separately measured steps — useful when a regression in one step should not hide inside a coarser, whole-`OnRun` measurement — but it is not what every sample does; `"BCPT Create Customer"` measures its entire `OnRun` as a single implicit scenario and never calls `StartScenario`/`EndScenario` at all. Choose per-step scenarios when step-level granularity matters to the flow being tested; otherwise a single measured `OnRun` is a legitimate, simpler choice.
See sample: [`bcpt-scenarios-must-be-app-specific.good.al`](bcpt-scenarios-must-be-app-specific.good.al).
## Anti Pattern
A PerformanceTest app whose only scenario codeunits are copies of Microsoft's shipped samples (creating a standard sales order, opening the standard customer list) tests the platform, not the extension. Any regression in the extension's own posting logic, calculations, or pages goes unmeasured and unnoticed.
See sample: [`bcpt-scenarios-must-be-app-specific.bad.al`](bcpt-scenarios-must-be-app-specific.bad.al).

View file

@ -0,0 +1,73 @@
codeunit 50142 "Sample Test Library"
{
Subtype = Test;
var
LibraryInventory: Codeunit "Library - Inventory";
Initialized: Boolean;
SharedItemNo: Code[20];
RollBackMsg: Label 'Revert back the tables to their original state.';
local procedure Initialize()
begin
if Initialized then
exit;
CreateSharedFixtureData();
// BUG: no Commit() here. The fixture below is still inside this
// test method's own transaction.
Initialized := true;
end;
local procedure CreateSharedFixtureData()
var
Item: Record Item;
begin
LibraryInventory.CreateItem(Item);
SharedItemNo := Item."No.";
end;
[Test]
procedure FirstTestUsesSharedFixture()
var
Item: Record Item;
begin
Initialize();
Item.Get(SharedItemNo);
Item.Description := 'Scratch change this test makes and does not need to keep.';
Item.Modify();
asserterror Error(RollBackMsg);
// The deliberate rollback above also erases the never-committed
// fixture from CreateSharedFixtureData(). Initialized still reads
// true on the next test, but the row it points at is gone.
end;
[Test]
procedure SecondTestStillFindsSharedFixture()
var
Item: Record Item;
begin
Initialize();
// Fails here: Initialize() saw Initialized = true and returned
// immediately, so it never recreated the fixture - and the first
// test's rollback took the original row with it.
Item.Get(SharedItemNo);
end;
}
codeunit 50143 "Sample Test Runner"
{
// Codeunit isolation alone does not save this fixture: TestIsolation
// only controls whether committed changes survive between methods, and
// this fixture was never committed in the first place.
Subtype = TestRunner;
TestIsolation = Codeunit;
trigger OnRun()
begin
Codeunit.Run(Codeunit::"Sample Test Library");
end;
}

View file

@ -0,0 +1,71 @@
codeunit 50142 "Sample Test Library"
{
Subtype = Test;
var
LibraryInventory: Codeunit "Library - Inventory";
Initialized: Boolean;
SharedItemNo: Code[20];
RollBackMsg: Label 'Revert back the tables to their original state.';
local procedure Initialize()
begin
if Initialized then
exit;
CreateSharedFixtureData();
Commit();
Initialized := true;
end;
local procedure CreateSharedFixtureData()
var
Item: Record Item;
begin
LibraryInventory.CreateItem(Item);
SharedItemNo := Item."No.";
end;
[Test]
procedure FirstTestUsesSharedFixture()
var
Item: Record Item;
begin
Initialize();
Item.Get(SharedItemNo);
Item.Description := 'Scratch change this test makes and does not need to keep.';
Item.Modify();
asserterror Error(RollBackMsg);
// Rolls back the Modify() above, but not the fixture: that was
// already committed inside Initialize().
end;
[Test]
procedure SecondTestStillFindsSharedFixture()
var
Item: Record Item;
begin
// Runs after FirstTestUsesSharedFixture's deliberate rollback.
// Initialize() sees Initialized = true and does nothing, but the
// committed fixture it created earlier is still there to Get().
Initialize();
Item.Get(SharedItemNo);
end;
}
codeunit 50143 "Sample Test Runner"
{
// Codeunit isolation: everything this codeunit's tests commit,
// including the shared fixture, survives from one test method to the
// next, and rolls back only once every method in the codeunit has run.
Subtype = TestRunner;
TestIsolation = Codeunit;
trigger OnRun()
begin
Codeunit.Run(Codeunit::"Sample Test Library");
end;
}

View file

@ -0,0 +1,34 @@
---
bc-version: [all]
domain: testing
keywords: [initialize, isinitialized, shared-fixture, commit, autocommit, asserterror, testisolation, lazy-initialization]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Commit shared fixture data created inside a lazy Initialize(), or later tests lose it
## Description
A test method with no `[TransactionModel(...)]` attribute defaults to `AutoCommit` (see `transactionmodel-attribute-governs-test-transactions.md`): a method that completes without error commits automatically at its own boundary, with no explicit `Commit()` needed. So a lazy/shared `Initialize()` — guarded by an `IsInitialized` flag, creating master/setup data once to avoid repeating expensive setup across many `[Test]` methods — does not need `Commit()` just to survive into the next test method; under the default model it already will. (Declaring `[TransactionModel(AutoRollback)]` instead is not compatible with this pattern at all: `AutoRollback` assumes the code under test never commits, and a `Commit()` call under it raises a runtime error.)
What an early `Commit()` inside `Initialize()` actually guards against is the test method's *own later, deliberate* rollback — the BCApps cleanup idiom of ending a test with `asserterror Error(SomeLabel)` to undo demo-data mutations that method made, so the run doesn't permanently dirty the database. Per the documented `Codeunit.Run` transaction semantics, changes are committed at the end of an execution "unless an error occurs" — an unhandled error rolls back whatever wasn't already committed. `Commit()` closes out the fixture's own transaction immediately, so it is unaffected by whatever the rest of that method does afterward, including that end-of-test error. Without the early `Commit()`, the same deliberate rollback wipes out the fixture too, even though `IsInitialized` still reads `true` on the next test, since it's a plain variable, not persisted data. BCApps' `codeunit 134915 "ERM Online Mapping Setup"` shows exactly this shape: no `TransactionModel` attribute, `Commit()` inside a lazy `Initialize()`, and the test itself ends with `asserterror Error(RollBackMessage)`.
Protecting the fixture from that same-method rollback is necessary but not sufficient for the fixture to reach a *later* test method — that also depends on the executing test runner's `TestIsolation`. Under `Disabled` (the property's own documented default) or `Codeunit` (used by BCApps' own `TestRunner`, `CLITestRunner`, and `SnapTestRunner` codeunits), nothing rolls back until the whole test codeunit finishes, so the already-committed fixture survives across every method run before then. These two are not interchangeable, though: `Codeunit` rolls back everything once the codeunit's last method completes, so the environment is clean afterward; `Disabled` never rolls back anything at all — "tests are not isolated from each other" is the property's own description — so a fixture this pattern commits stays in the database permanently unless something else explicitly deletes it. Under `Function`, the runner rolls back all database changes — explicitly including ones already committed via `Commit()` — after every single test method; no amount of committing inside `Initialize()` makes a fixture shared across methods survive that regime, because the whole premise of a lazy, once-per-codeunit fixture doesn't hold when every method is isolated from every other.
## Best Practice
When a test method's own cleanup relies on ending in a deliberate error to roll back its scratch changes, call `Commit()` once, inside the lazy `Initialize()` guard, right after the shared fixture is created — before that cleanup-triggering error can run. This pattern only delivers a fixture shared across test methods when the executing runner's `TestIsolation` is `Disabled` or `Codeunit`; do not recommend it, or pair it with, a `Function`-isolated runner — that configuration undoes the committed fixture after every method regardless. Recommend `TestIsolation = Codeunit`: it gives every method in the codeunit the same shared, committed fixture and still leaves the database clean once the codeunit finishes. Recommend `Disabled` only alongside an explicit, verified teardown step that removes the fixture data at the end of the run — without one, the committed fixture is permanent contamination, not a controlled trade-off.
See sample: [`commit-shared-test-fixture-inside-lazy-initialize.good.al`](commit-shared-test-fixture-inside-lazy-initialize.good.al).
## Anti Pattern
A shared `Initialize()` guarded by `IsInitialized` that creates fixture records without committing, in a test method that ends with a deliberate `asserterror Error(...)` to undo its own scratch changes, run under a `Disabled`- or `Codeunit`-isolated test runner. That rollback also erases the never-committed fixture; the next test still finds `IsInitialized = true` but the rows it depends on are gone. (Under a `Function`-isolated runner the fixture is lost regardless of `Commit()`, for the unrelated reason above — that is a runner-configuration problem, not this anti-pattern.)
See sample: [`commit-shared-test-fixture-inside-lazy-initialize.bad.al`](commit-shared-test-fixture-inside-lazy-initialize.bad.al).
## Source
The shared/lazy `Initialize()` pattern and its `Commit()` call are drawn from Luc van Vugt's "Let's talk about Shared Fixture and how to profit from this with the Dynamics NAV Test Toolkit": https://www.fluxxus.nl/index.php/bc/let39s-talk-about-shared-fixture-and-how-to-profit-from-this-with-the-dynamics-nav-test-toolkit/. That post shows the `Commit()` call in its `Initialize()` example but does not explain the transaction mechanics behind it; the `AutoCommit`-default, `Codeunit.Run`-error, and `TestIsolation`-level analysis above is this article's own, verified independently against Microsoft's TransactionModel/TestIsolation documentation and BCApps' `codeunit 134915 "ERM Online Mapping Setup"` source, not taken from the post.

View file

@ -0,0 +1,15 @@
[Test]
procedure PostSalesOrder_CreatesInvoice()
var
SalesHeader: Record "Sales Header";
SalesInvoiceHeader: Record "Sales Invoice Header";
InvoiceNo: Code[20];
begin
// [GIVEN] a sales order — posting groups left to whatever exists in the test company
LibrarySales.CreateSalesOrder(SalesHeader);
// [WHEN]
InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, false, true);
// [THEN]
SalesInvoiceHeader.Get(InvoiceNo);
Assert.RecordIsNotEmpty(SalesInvoiceHeader);
end;

View file

@ -0,0 +1,20 @@
[Test]
procedure PostSalesOrder_CreatesInvoice()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
SalesInvoiceHeader: Record "Sales Invoice Header";
InvoiceNo: Code[20];
begin
// [GIVEN] a customer
LibrarySales.CreateCustomer(Customer);
// [GIVEN] a sales order for that customer
LibrarySales.CreateSalesOrderForCustomerNo(SalesHeader, Customer."No.");
SalesHeader.Validate("Posting Date", WorkDate());
SalesHeader.Modify(true);
// [WHEN]
InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, false, true);
// [THEN]
SalesInvoiceHeader.Get(InvoiceNo);
Assert.RecordIsNotEmpty(SalesInvoiceHeader);
end;

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [given, test-setup, posting, report, request-page, precondition, completeness]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Cover the full precondition chain in GIVEN, not just the primary record
## Description
A `[GIVEN]` block is only correct if it sets up every precondition the code under test actually reads, not just the record the scenario is "about." For most master-data tests, creating the primary record is enough. For posting routines and reports it usually is not: an incomplete `[GIVEN]` produces a test that either fails with a setup error unrelated to the scenario, or worse, passes without ever reaching the logic it claims to verify.
## Best Practice
For a posting test, set up the full posting-group chain the document requires (e.g. customer/vendor posting group, gen. business/product posting group, VAT posting setup), the setup records the specific posting path reads, and an explicit date when the path is date-sensitive — a missing link surfaces as an unrelated G/L error, not a meaningful test failure. For a report test that claims to verify filtering or dataset logic, include both a record that should be included and one that should be excluded, plus any request-page parameter or FlowField the report's logic branches on. A report test that only claims to run without error is exempt from the include/exclude pairing, but it must say so in its scenario name or comment — an unlabelled single-record `[GIVEN]` is ambiguous about which claim it is making, and that ambiguity is itself the defect.
See sample: [`given-blocks-must-cover-full-precondition-chain.good.al`](given-blocks-must-cover-full-precondition-chain.good.al).
## Anti Pattern
A posting test whose `[GIVEN]` creates only the sales header, relying on whatever posting groups happen to exist in the test company. A report test whose `[GIVEN]` creates only matching records, so the report "passes" whether or not its filter logic does anything at all.
See sample: [`given-blocks-must-cover-full-precondition-chain.bad.al`](given-blocks-must-cover-full-precondition-chain.bad.al).

View file

@ -0,0 +1,57 @@
codeunit 50411 "Test Sales Setup Initialize Bad"
{
Subtype = Test;
[Test]
[HandlerFunctions('CustomerCardHandler')]
procedure CustomerCardOpensForSelectedCustomer()
var
Customer: Record Customer;
begin
Initialize();
LibrarySales.CreateCustomer(Customer);
LibraryVariableStorage.Enqueue(Customer."No.");
Page.RunModal(Page::"Customer Card", Customer);
LibraryVariableStorage.AssertEmpty();
end;
[Test]
procedure StockoutWarningCanBeDisabled()
var
SalesSetup: Record "Sales & Receivables Setup";
begin
Initialize();
LibrarySales.SetStockoutWarning(false);
SalesSetup.Get();
Assert.IsFalse(SalesSetup."Stockout Warning", 'The stockout warning was not disabled.');
end;
local procedure Initialize()
begin
if IsInitialized then
exit;
LibraryVariableStorage.Clear();
LibrarySetupStorage.Restore();
LibrarySales.SetStockoutWarning(true);
IsInitialized := true;
LibrarySetupStorage.SaveSalesSetup();
end;
[ModalPageHandler]
procedure CustomerCardHandler(var CustomerCard: TestPage "Customer Card")
begin
Assert.AreEqual(LibraryVariableStorage.DequeueText(), CustomerCard."No.".Value(), 'The customer card opened for the wrong customer.');
end;
var
Assert: Codeunit Assert;
LibrarySales: Codeunit "Library - Sales";
LibrarySetupStorage: Codeunit "Library - Setup Storage";
LibraryVariableStorage: Codeunit "Library - Variable Storage";
IsInitialized: Boolean;
}

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