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,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,17 @@
codeunit 50104 "Import File Reader"
{
procedure ImportFile()
var
TempBlob: Codeunit "Temp Blob";
InStream: InStream;
FileName: Text;
begin
if UploadIntoStream('Import file', '', 'All Files (*.*)|*.*', FileName, InStream) then
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 — only works on-premises, because there is no accessible filesystem in the SaaS/cloud sandbox. Any extension meant to run in Business Central Online must not rely on `File.Open`, `File.Create`, `File.Read`, or `File.Write` for its core functionality: code built this way compiles but fails, or is silently skipped, in the cloud.
## 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`.
## 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`.

View file

@ -0,0 +1,13 @@
codeunit 50103 "Order Confirmation Notifier"
{
procedure Send(FromName: Text; ToAddress: Text; Subject: Text; Body: Text)
var
Mail: Codeunit Mail;
MailSent: Boolean;
begin
Mail.CreateMessage(FromName, ToAddress, '', Subject, Body, true);
MailSent := Mail.Send();
if not MailSent then
Message(Mail.GetErrorDesc());
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`, table `Email Message`, `enum "Email Scenario"`, and the `Email Account`/`Email Connector` interface (Microsoft 365, Current User, SMTP, or a custom connector). 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 table `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`.
## Anti Pattern
Calling `Codeunit Mail`'s `CreateMessage`/`Send`/`GetErrorDesc`. It still runs, but it is hard-coupled to whatever SMTP setup exists, and leaves no queryable record of what was sent.
See sample: `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,32 @@
codeunit 50101 "Meter Jnl.-Post Line"
{
procedure PostLine(var MeterJnlLine: Record "Meter Journal Line")
var
MeterLedgEntry: Record "Meter Ledger Entry";
begin
// Writes exactly one ledger entry; 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
Every journal-based posting routine in Business Central is split across three companion codeunits with distinct, non-overlapping responsibilities: `Check Line` validates one line, `Post Line` writes exactly one line to the ledger, 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`. A new posting routine that blurs this split 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`.
## 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`.

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,28 @@
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.ValidateShortcutDimCode(1, "Global Dimension 1 Code");
DimMgt.SaveDefaultDim(Database::Course, "No.", FieldNo("Global Dimension 1 Code"), "Global Dimension 1 Code");
end;
}
}
trigger OnDelete()
var
DimMgt: Codeunit DimensionManagement;
begin
DimMgt.DeleteDefaultDim(Database::Course, "No.");
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: data-modeling
keywords: [dimensions, dimensionmanagement, global-dimension, shortcut-dimension, default-dimension, validateshortcutdimcode, createdim]
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 master or document table is not just a matter of adding a `Code[20]` field. Business Central expects a specific set of hooks into `Codeunit "Dimension Management"` so a dimension value is validated, persisted as a Default Dimension record, and flows through to transactions the same way it does for every standard table. Skipping any one hook produces a field that looks correct in the designer but silently fails to save, validate, or carry through to postings.
## Best Practice
A master table should validate its dimension fields through `ValidateShortcutDimCode` and `SaveDefaultDim`, and create/delete the matching Default Dimension records in `OnInsert`/`OnDelete`. A document table should add Shortcut Dimension fields validated the same way, and call `CreateDim` to pull dimension values from the related master record whenever the field that attaches the document to that master changes.
See sample: `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`.

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

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; "Period Start") { Clustered = true; }
}
}

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

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,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,28 @@
---
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
A document report — an invoice, statement, order confirmation, or any report meant to be printed, emailed, or exported as a single-record document — should default to a `Word` rendering layout rather than `RDLC`. RDLC layouts run in a sandboxed app domain that only lives for the current report invocation, which is slower for UI-related actions such as emailing the resulting document, than a Word layout, which is not subject to that sandbox constraint. This 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
Set `DefaultRenderingLayout = Word` and define a `Word` layout for reports that represent one structured document per record. Reserve RDLC (or Excel) for reports that represent a data listing rather than a document.
See sample: `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`.

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

View file

@ -0,0 +1,14 @@
[Test]
procedure PostsSalesOrderForCashCustomer()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
begin
// Assumes a 'CASH' customer already exists in the environment —
// fails on any database where it doesn't.
Customer.Get('CASH');
LibrarySales.CreateSalesHeader(
SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
// ... add lines, post, assert ...
end;

View file

@ -0,0 +1,13 @@
[Test]
procedure PostsSalesOrderForRandomCustomer()
var
Customer: Record Customer;
SalesHeader: Record "Sales Header";
begin
// Random, collision-free customer — no assumption about what exists.
LibrarySales.CreateCustomer(Customer);
LibrarySales.CreateSalesHeader(
SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
// ... add lines, post, assert ...
end;

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: testing
keywords: [testing, test-data, random, library, any]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Generate Test Data Programmatically, Never Assume Existing Records
> Contributions welcome — open a PR to refine or extend this article.
## Description
An AL test suite should assume an empty database. Test data must be created programmatically inside the test rather than assuming a specific code, number, or name already exists in the environment — a hardcoded lookup against an assumed-existing record makes the test fail for reasons unrelated to the code under test. Every mandatory field on a created record also needs a value that respects its declared length; a partial setup that merely passes validation is not sufficient.
## Best Practice
Use the standard library codeunits (`Library - ERM`, `Library - Inventory`, `Library - Sales`, `Library - Utility`) to create records with collision-free random values, and fill every mandatory field with randomized, correctly-sized data. Reserve hardcoded values for tests that validate an external contract itself — a fixed JSON schema, an EDIFACT message, a counterparty code — where the hardcoded value documents the specification rather than arbitrary test logic.
See sample: `test-data-must-be-random-and-complete.good.al`.
## Anti Pattern
Looking up a record assumed to already exist (a hardcoded payment method or customer number) instead of creating it, or leaving mandatory fields empty or underfilled because validation happens to allow it.
See sample: `test-data-must-be-random-and-complete.bad.al`.

View file

@ -0,0 +1,24 @@
page 50100 "Item Availability API"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'inventory';
APIVersion = 'v1.0';
EntityName = 'itemAvailability';
EntitySetName = 'itemAvailabilities';
SourceTable = Item;
layout
{
area(content)
{
repeater(General)
{
field(itemNo; Rec."No.") { }
field(quantityOnHand; Rec.Inventory) { }
// No OnAfterGetRecord CalcFields — Inventory is a FlowField
// and returns 0 to every consumer.
}
}
}
}

View file

@ -0,0 +1,27 @@
page 50100 "Item Availability API"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'inventory';
APIVersion = 'v1.0';
EntityName = 'itemAvailability';
EntitySetName = 'itemAvailabilities';
SourceTable = Item;
layout
{
area(content)
{
repeater(General)
{
field(itemNo; Rec."No.") { }
field(quantityOnHand; Rec.Inventory) { }
}
}
}
trigger OnAfterGetRecord()
begin
Rec.CalcFields(Inventory);
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: web-services
keywords: [api-page, flowfield, calcfields, odata]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Explicitly Calculate FlowFields on API Pages
> Contributions welcome — open a PR to refine or extend this article.
## Description
FlowFields are not stored in the database — Business Central computes them on demand. Regular pages trigger that calculation automatically while rendering, but API pages do not. A FlowField referenced in an API page's layout returns an empty value to external consumers unless it is calculated explicitly, producing a silent data gap in OData responses that is easy to miss in review.
## Best Practice
Call `CalcFields` for every FlowField referenced in the page layout from the `OnAfterGetRecord` trigger, combining multiple fields into a single call.
See sample: `api-page-flowfields-must-be-calcfields.good.al`.
## Anti Pattern
Relying on the implicit calculation that regular pages perform. Any FlowField left out of the `CalcFields` call returns a blank value to every API consumer with no visible error.
See sample: `api-page-flowfields-must-be-calcfields.bad.al`.

View file

@ -0,0 +1,27 @@
page 50101 "Customer Info API"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v1.0';
EntityName = 'customerInfo';
EntitySetName = 'customerInfos';
SourceTable = Customer;
ODataKeyFields = "No.";
InsertAllowed = true;
layout
{
area(content)
{
repeater(General)
{
field(customerNo; Rec."No.")
{
Editable = false; // consumer must supply "No." on POST — this rejects it
}
field(name; Rec.Name) { }
}
}
}
}

View file

@ -0,0 +1,24 @@
page 50101 "Customer Info API"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'sales';
APIVersion = 'v1.0';
EntityName = 'customerInfo';
EntitySetName = 'customerInfos';
SourceTable = Customer;
ODataKeyFields = "No.";
InsertAllowed = true;
layout
{
area(content)
{
repeater(General)
{
field(customerNo; Rec."No.") { }
field(name; Rec.Name) { }
}
}
}
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: web-services
keywords: [api-page, key-fields, editable, insert, odata]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep Consumer-Provided Key Fields Editable on API Pages
> Contributions welcome — open a PR to refine or extend this article.
## Description
A field listed in `ODataKeyFields` cannot have `Editable = false` when the API page allows inserts and the field's value must be supplied by the caller. Marking it read-only removes the field from the OData write schema, so a POST that includes it is rejected as an unknown property. This only applies to keys the consumer must supply — a system-generated key such as `SystemId` is a valid exception, since Business Central assigns its value automatically on insert.
## Best Practice
Leave every consumer-supplied key field referenced in `ODataKeyFields` without `Editable = false` on pages where `InsertAllowed = true`, so the OData layer accepts it as a writable property on POST.
See sample: `api-page-key-fields-must-be-editable-on-insert.good.al`.
## Anti Pattern
Marking a consumer-provided key field `Editable = false`, out of habit or for perceived safety. This silently breaks create operations with a generic `BadRequest` instead of a clear validation error.
See sample: `api-page-key-fields-must-be-editable-on-insert.bad.al`.

View file

@ -0,0 +1,24 @@
page 50102 "Project Task API"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'jobs';
APIVersion = 'v1.0';
EntityName = 'projectTask';
EntitySetName = 'projectTasks';
SourceTable = "Project Task";
layout
{
area(content)
{
repeater(General)
{
// "Remaining Hours" is a stored field, set inside the
// OnValidate of "Budgeted Hours" — it goes stale whenever
// "Hours Used" changes through any other path.
field(remainingHours; Rec."Remaining Hours") { }
}
}
}
}

View file

@ -0,0 +1,31 @@
page 50102 "Project Task API"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'jobs';
APIVersion = 'v1.0';
EntityName = 'projectTask';
EntitySetName = 'projectTasks';
SourceTable = "Project Task";
layout
{
area(content)
{
repeater(General)
{
field(remainingHours; RemainingHoursCalc) { }
field(hoursUsed; Rec."Hours Used") { }
}
}
}
trigger OnAfterGetRecord()
begin
Rec.CalcFields("Hours Used");
RemainingHoursCalc := Rec."Budgeted Hours" - Rec."Hours Used";
end;
var
RemainingHoursCalc: Decimal;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: web-services
keywords: [api-page, derived-fields, exposure, odata]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Recalculate Stored Derived Fields Before Exposing Them on API Pages
> Contributions welcome — open a PR to refine or extend this article.
## Description
A stored field whose value is derived from other fields inside an `OnValidate` trigger only updates when that specific trigger fires. If the underlying source data changes through some other path, the stored value goes stale without raising any error. Exposing such a field directly on an API page hands external consumers a snapshot that may be significantly out of date.
## Best Practice
Recalculate the derived value in `OnAfterGetRecord` from its authoritative source — typically a FlowField — using a page-level variable, and expose both the recalculated value and the source field so the consumer can verify it.
See sample: `stored-derived-fields-must-not-be-exposed-directly.good.al`.
## Anti Pattern
Exposing the stored field directly via `Rec`, trusting that it was kept in sync by whichever trigger last touched it.
See sample: `stored-derived-fields-must-not-be-exposed-directly.bad.al`.