Add 18 community AL/BC patterns across style, data-modeling, web-services, appsource, breaking-changes, performance, and testing

Contributed by CURABIS ApS, generalized from patterns observed across real AppSource/PTE development. Each article follows the knowledge file format (frontmatter, Description/Best Practice/Anti Pattern, sibling .good.al/.bad.al samples).
This commit is contained in:
Michael Dieringer 2026-09-04 20:43:00 +02:00
parent 07e324ddbc
commit 057e17c202
52 changed files with 1109 additions and 0 deletions

View file

@ -0,0 +1,24 @@
---
bc-version: [all]
domain: style
keywords: [build, output, alpackages, duplicate, language-server, app-package, project-root, al0197]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep AL Build Output Out of the Project Root
> Contributions welcome — open a PR to refine or extend this article.
## Description
When an AL project is built, the compiled `.app` file is placed in the project root by default. Over successive builds, multiple `.app` files accumulate there (e.g. one per version). The AL language server, both in the editor and in build tooling, scans the project folder for symbol packages and can load these compiled artefacts alongside the live source files, which produces `AL0197` duplicate-object errors for every object in the project — with messages that point at source lines rather than at the packaged artefact that is the actual duplicate. The errors are not real; they disappear as soon as the stale `.app` files are removed from the root.
## Best Practice
Configure the build output path to a dedicated subfolder that is excluded from language server scanning — for example by setting `al.outputPath` to a folder such as `.output` in `.vscode/settings.json`, or by passing an explicit output path to the build tool being used — and add that folder to `.gitignore`. Before treating an `AL0197` "already declared" error as a source code problem, check the project root for stale `.app` files first; adding root `.app` files to `.gitignore` instead of relocating the output path only hides the accumulation rather than fixing it.
## Anti Pattern
Letting `.app` files accumulate in the project root across builds, then debugging the resulting `AL0197` duplicate-object errors as if they were a source code defect instead of first checking for stale build artefacts in the root folder.

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`.
## 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`.

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 exactly two states — yes/no, on/off, active/inactive, blocked/not blocked — it should be typed `Boolean`. Modeling that same two-state choice 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, and it invites a multi-branch check where a simple `if X then` would do. This is distinct from a genuine multi-value choice with more than two named states, which legitimately calls for `Enum` — the line is the state count.
## Best Practice
Type a true two-state field or variable as `Boolean` and branch on it directly.
See sample: `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`.

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 can only take on a fixed set of more than two 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 distinct from a true two-state choice, which should be `Boolean` rather than an enumeration — the line is the state count.
## 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`.
## 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`.

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 — the VS Code tooling's default formatter output, 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. 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`.
## 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`.

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"; // resolves in a local build, fails in VS Code
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]
---
# Verify a namespace from the object's own source file, never by inference
> 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. Guessing a namespace from an object's name, from an older codebase, or from general familiarity produces a `using` statement that can look plausible, compile in isolation, and still resolve to the wrong object or fail in the AL Language Server that VS Code actually uses to report errors. The only reliable source for an object's namespace is line one of that object's own source file.
## Best Practice
Locate the object's source file, read its `namespace` declaration on line one, and copy that exact value into the consuming file's `using` statement.
See sample: `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 compile in one build environment while still failing to resolve in VS Code, because the two use different namespace resolution.
See sample: `namespace-must-be-verified-from-source.bad.al`.

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`.
## 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`.