18 more AL/BC patterns: data-modeling, testing, style, security, error-handling, ui, upgrade, web-services, appsource (#157)

* Add 18 more community AL/BC patterns across appsource, data-modeling, error-handling, security, style, testing, ui, upgrade, and web-services

Second contribution from CURABIS ApS, generalized from patterns observed across real AppSource/PTE development. Cross-checked against the current microsoft/knowledge corpus before opening; several originally-drafted candidates were dropped as duplicates of existing files.

* Address Jesper Schulz-Wedde's review on PR #157

- release-must-update-app-version.md: reframe around AppSource's actual
  strict full-version-ordering requirement; scope branching-policy
  claims as team convention, not platform rule.
- pictures-must-use-media-not-blob.md: MediaSet is a collection of
  independent media objects, not automatic image variants/thumbnails.
- log-writes-must-survive-rollback.{md,good.al}: StartSession's only
  data channel into the new session is its Record parameter to a
  TableNo-scoped codeunit; a setter called on a local instance before
  starting the session populates nothing in the new session.
- exposed-objects-must-be-in-a-permission-set.md: correct the three
  exposure mechanisms (Web Services config, PageType/QueryType=API,
  ServiceEnabled as a method-only attribute).
- pages-must-not-contain-business-logic.md: scope to persisted
  mutations and cross-entry-point rules; presentation-only
  calculations and table-owned invariants are not violations.
- given-blocks-must-cover-full-precondition-chain.good.al: replace
  invented LibrarySales calls with the real API
  (CreateCustomer/CreateSalesOrderForCustomerNo/PostSalesDocument).
- test-feature-scenario-tags.{md,good.al}: move [SCENARIO] inside the
  test procedure body to match the current BCApps corpus; keep
  [FEATURE] at codeunit level per Microsoft's own documented option.
- ui-test-codeunit-naming.md: scope the _UT suffix and adjacent-ID
  pairing as an explicit team convention, not a BCApps-wide standard.
- page-design-must-match-bc-page-type-conventions.md /
  table-design-must-match-bc-table-type-conventions.md: Card's
  single-key primary-key claim is a contextual heuristic, not a
  mandatory constraint (Ship-to Address, Customer/Vendor Bank Account
  are real composite-key Card pages); a Subsidiary table with its own
  identity commonly gets List+Card, not Worksheet/Tabular.
- api-page-least-privilege-write-access.{md,good.al}: only page-placed
  fields are ever exposed; set InsertAllowed/DeleteAllowed=false in the
  good sample so a narrow field set can't still create/delete records.
- source-organized-by-feature-not-object-type.md,
  test-one-when-per-test.md: scope as team/testing-design conventions,
  not Microsoft platform requirements.
- upgrade-tag-logic-must-not-nest-deeply.md: add the Microsoft Learn
  citation that already backs the two-level nesting limit.
- Wire the new articles into the testing/data-modeling/error-handling/
  security/ui review skills' candidate-selection signals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Address second round of Jesper Schulz-Wedde's review on PR #157

- log-writes-must-survive-rollback.good.al: fixed invalid trigger
  OnRun(var Rec: ...) declaration; Rec is implicit when TableNo is set.
- exposed-objects-must-be-in-a-permission-set.md: distinguished the three
  exposure mechanisms (page/query web service or API, codeunit published
  as a web service, [ServiceEnabled] bound action on a page) and their
  actual permission targets (page/query "..." = X vs codeunit "..." = X).
- code-must-not-change-workdate.md: scoped from an absolute "never" to
  "not as a side effect of unrelated logic" - verified real WorkDate(x)
  setter usage in BCApps demo-data generators and test codeunits.
- bcpt-scenarios-must-be-app-specific.md: SingleInstance and
  StartScenario/EndScenario reframed as context-dependent patterns, not
  mandatory requirements - BCPT Create Customer uses neither.
- test-feature-scenario-tags.good.al/.bad.al: replaced the invented
  LibrarySales.CreateCustomerWithPrice/"Item Price Mgt." calls with a real,
  verified price-list-line test using Library - Sales/Library - Inventory/
  Library - Price Calculation.
- page-design-must-match-bc-page-type-conventions.md: scoped the missing
  UsageCategory anti-pattern to pages intended as searchable entry points.
- defensive-vs-offensive-code-must-match-blast-radius.md/.good.al/.bad.al:
  replaced the VAT registration number "low blast radius" example with a
  genuinely cosmetic field (customer home page URL).
- source-organized-by-feature-not-object-type.md: anti-pattern reframed as
  inconsistency with a repo's own convention, not the object-type scheme
  itself.
- pictures-must-use-media-not-blob.md: removed leftover "image variants"
  wording contradicting the already-corrected MediaSet description.

Proactively fixed while sweeping all fixtures for invented APIs:
- given-blocks-must-cover-full-precondition-chain.bad.al: PostSalesOrder
  called with wrong arity and referenced an undeclared variable.
- ui-test-codeunit-naming.good.al/.bad.al: replaced the same fake
  "Item Price Mgt."/TestPage "Item Price" with real Library - Sales calls
  and the real Customer Card TestPage.

Worklist completeness: added review-skill cues for the 12 of 18 new rules
that had none (al-appsource-review.md, al-data-modeling-review.md,
al-error-handling-review.md, al-security-review.md, al-style-review.md x3,
al-testing-review.md x2, al-ui-review.md, al-upgrade-review.md,
al-web-services-review.md), and fixed test-feature-scenario-tags' cue,
which only matched the compliant (tagged) shape instead of the anti-pattern
(untagged/generic-named test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix ten focused correctness items plus sample links from Jesper's 2026-09-15 re-review

Six carried-over threads:
- api-page-least-privilege-write-access fixtures: added the mandatory
  EntityName/EntitySetName properties (AL0485).
- pages-must-not-contain-business-logic fixtures: Sales Line has no
  "Total Amount" field; replaced with the real "Line Amount" (field 103).
- test-feature-scenario-tags.good.al and test-one-when-per-test.good.al:
  CreatePriceHeader leaves a price list in Draft status, which price
  calculation ignores. Added Validate(Status, Active) + Modify before the
  sales line that depends on it. Verified Status field/enum against
  PriceListHeader.Table.al and PriceStatus.Enum.al in the BCApps clone.
- exposed-objects-must-be-in-a-permission-set.md: a published codeunit is
  a SOAP endpoint (SOAP is deprecated), not OData - Page/Query are the
  OData object types. Corrected and pointed new integrations at API
  pages/queries instead.
- al-error-handling-review.md: the log-writes-must-survive-rollback cue
  selected on Session.StartSession, which only appears in the compliant
  fix, never in the anti-pattern - the bad fixture could never be
  worklisted. Recued on the actual risk shape (log insert around a
  failed TryFunction/GetLastError* path, then raise/propagate), with
  StartSession as an explicit compliant discriminator instead.
- page-design-must-match-bc-page-type-conventions.md: the enum value is
  NavigatePage, not Navigate; noted the type list is a selected subset,
  not an exhaustive PageType catalogue (PromptDialog, ConfigurationDialog,
  UserControlHost, XmlPort also exist, out of this article's scope).

Four new correctness gaps:
- release-must-update-app-version.md: "the version is the only identity"
  was backwards - id is the app's stable identity, version identifies a
  release/code-state of it.
- defensive-vs-offensive-code-must-match-blast-radius.good.al: the "low
  blast radius" example had no else branch, so a failed Customer.Get()
  left the field at its prior/default value instead of the explicit
  chosen fallback the article claims to demonstrate. Added the else.
- bcpt-scenarios-must-be-app-specific.good.al: InitTest and both measured
  StartScenario/EndScenario sections were empty/comment-only, so the
  "app-specific" fixture measured no actual work. Filled in a real,
  self-contained header+line creation path.
- upgrade-tag-logic-must-not-nest-deeply.good.al: the flattened version
  dropped both safety conditions the bad fixture had (Discount % = 0,
  nonblank posting group), silently changing behavior instead of just
  removing nesting. Extracted the guarded update into a helper with both
  conditions preserved as early exits.

Also converted this PR's remaining plain-backtick "See sample:" sample
references (16 articles) to the READ-convention markdown-link form,
matching the fix already made on #156/#158.

Rebased onto upstream/main (conflicts in al-ui-review.md, al-style-review.md,
al-upgrade-review.md against merged upstream PRs - all additive, both
sides' worklist cues retained).

* Fix four merge-critical issues from Jesper's 2026-09-22 review

- pages-must-not-contain-business-logic.good.al/.bad.al: the "good"
  codeunit still directly assigned real Sales Line."Line Amount" and
  called Modify(), bypassing the field's normal Validate cascade
  (discount, VAT, related-amount maintenance) - persisting inconsistent
  document lines regardless of which object the code lived in. Replaced
  the real Sales Line example with a self-contained "Sample Order Line"
  table and switched the codeunit to Validate()/Modify(true), so the
  fixture demonstrates the page-vs-codeunit separation without teaching
  unsafe direct field writes to a real BC document table.
- bcpt-scenarios-must-be-app-specific.good.al: Customer.FindFirst()
  assumed a pre-existing customer (fails against an empty environment),
  and a session-local NextNo counter for the header key collides across
  concurrent BCPT sessions and repeated runs. Creates its own customer
  when none exists, and generates keys from CreateGuid() instead of an
  in-memory counter.
- upgrade-tag-logic-must-not-nest-deeply: the rule conflated two
  different things - nesting one tag's existence check inside another
  (the real anti-pattern Microsoft's guidance warns against) with
  having business-data safety conditions inside a single tagged
  migration's own loop body (which Microsoft's own worked example does,
  and its own design guidance explicitly requires: "Implement extra
  safety checks to avoid data corruption, even though you're using
  upgrade tags"). Rewrote the Description/Best Practice/Anti Pattern to
  scope the rule to actual tag nesting and migrations blended under one
  tag, and rewrote both fixtures: good.al now shows two safety
  conditions correctly nested inside one migration's own loop plus a
  second, genuinely separate migration as its own flat tagged
  procedure; bad.al now shows the real anti-pattern, one tag's check
  nested inside another's guarded body.
- table-design-must-match-bc-table-type-conventions: the rule and its
  worklist cue fired on any new table with a keys block, forcing
  buffers, queues, logs, mapping tables, and staging tables into the
  nearest-looking one of nine business-record archetypes. Added an
  explicit scope note that these nine types aren't an exhaustive table
  catalogue, and narrowed the al-data-modeling-review.md cue to require
  positive evidence (a type-specific naming suffix, key shape, or
  usage) before worklisting, instead of a bare keys/primary-key
  declaration.

* Narrow upgrade-tag nesting cue to match revised article

Cue now flags only nested upgrade-tag checks or functionally unrelated
migrations under one tag, and explicitly excludes record loops and
business-data safety guards belonging to a single migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Michael Dieringer 2026-09-29 17:12:51 +02:00 • committed by GitHub
parent 4287233f80
commit f63943dcfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1612 additions and 2 deletions

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 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,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,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,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

@ -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,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,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,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,33 @@
codeunit 50102 "Item Price Testing"
{
Subtype = Test;
var
LibrarySales: Codeunit "Library - Sales";
LibraryInventory: Codeunit "Library - Inventory";
LibraryPriceCalculation: Codeunit "Library - Price Calculation";
Assert: Codeunit "Library Assert";
[Test]
procedure Test1()
var
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// setup mixed with assertions, no clear layers, no FEATURE/SCENARIO/GIVEN/WHEN/THEN tags
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
end;
}

View file

@ -0,0 +1,40 @@
// [FEATURE] Item Price — price cascade (Customer -> Price Group -> All Customers)
codeunit 50103 "Item Price Testing"
{
Subtype = Test;
var
LibrarySales: Codeunit "Library - Sales";
LibraryInventory: Codeunit "Library - Inventory";
LibraryPriceCalculation: Codeunit "Library - Price Calculation";
Assert: Codeunit "Library Assert";
[Test]
procedure GetPrice_CustomerPrice_ReturnsUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [SCENARIO] Customer with a specific price list line gets that unit price
// [GIVEN] a customer and an item with a customer-specific sales price list line
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// CreatePriceHeader leaves the list in Draft status, which price calculation ignores.
PriceListHeader.Validate(Status, PriceListHeader.Status::Active);
PriceListHeader.Modify(true);
// [WHEN] a sales line is created for that customer and item
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [THEN] the sales line picks up the customer's price list line
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", 'Unit price must match customer price list');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [feature, scenario, given, when, then, tags, bdd, atdd, comments]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Tag test codeunits with FEATURE, SCENARIO, GIVEN, WHEN, and THEN comments
## Description
Test codeunits are easier to trust and to review when they carry a four-level comment structure taken from Behaviour-/Acceptance-Test-Driven Development: `[FEATURE]` once at the top of the codeunit naming the functional area under test, `[SCENARIO]` above each test procedure stating one falsifiable business claim in plain language, and `[GIVEN]`/`[WHEN]`/`[THEN]` marking the precondition, action, and assertion inside the test body. Without these tags a test procedure is an opaque block of AL that only reveals its intent by being read line by line; a reviewer or product owner cannot scan a codeunit and know what business behaviour it covers.
## Best Practice
Put `[FEATURE]` as a comment before the codeunit's opening brace, naming the domain rather than the object — Microsoft's own guidance allows setting it once for the whole codeunit, inherited by every test in it. Put `[SCENARIO]`, matching the current BCApps corpus, as the first comment inside each test procedure's body (after `begin`), describing the scenario in business language that complements — not duplicates — the procedure name, followed by `[GIVEN]` marking the precondition setup, `[WHEN]` marking the single action under test, and `[THEN]` marking the assertions. The procedure name stays the machine-readable identity shown in test-runner output; the `[SCENARIO]` comment stays the human-readable one. Neither replaces the other.
See sample: [`test-feature-scenario-tags.good.al`](test-feature-scenario-tags.good.al).
## Anti Pattern
A test procedure with no `[FEATURE]`/`[SCENARIO]`/`[GIVEN]`/`[WHEN]`/`[THEN]` structure, setup mixed freely with assertions, and a procedure name like `Test1` that says nothing about what is being verified. Nothing in the codeunit tells a reader what business rule it exists to protect.
See sample: [`test-feature-scenario-tags.bad.al`](test-feature-scenario-tags.bad.al).

View file

@ -0,0 +1,32 @@
[Test]
procedure GetPrice_ThenGetPriceLines_ReturnsCorrectValues()
var
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [GIVEN] ...
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [WHEN] first action
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [WHEN] second action — this is a second test in disguise
PriceListLine.Validate("Minimum Quantity", 10);
PriceListLine.Modify(true);
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [THEN] asserting two unrelated things
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
Assert.AreEqual(2, PriceListLine.Count(), '');
end;

View file

@ -0,0 +1,56 @@
[Test]
procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [GIVEN] a customer with a price list line for the item
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// CreatePriceHeader leaves the list in Draft status, which price calculation ignores.
PriceListHeader.Validate(Status, PriceListHeader.Status::Active);
PriceListHeader.Modify(true);
// [WHEN]
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [THEN]
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", 'Unit price must match price list');
end;
[Test]
procedure GetPriceLines_TwoMinimumQuantityLines_ReturnsBoth()
var
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
begin
// [GIVEN] a customer price list with two minimum-quantity price lines for the same item
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
PriceListLine.Validate("Minimum Quantity", 10);
PriceListLine.Modify(true);
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
PriceListLine.Validate("Minimum Quantity", 50);
PriceListLine.Modify(true);
// [WHEN]
PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
// [THEN]
Assert.AreEqual(2, PriceListLine.Count(), 'Exactly two price lines expected');
end;

View file

@ -0,0 +1,34 @@
---
bc-version: [all]
domain: testing
keywords: [when, single-action, bdd, atdd, given-when-then, flow-test, regression-test]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep exactly one WHEN per test, with narrow exceptions for flow and defect-then-fix tests
## Description
This is a testing-design practice, not a BC platform requirement — no AL API enforces it, and it should not gate a change the way a platform-contradicted claim would. Each test procedure should contain exactly one `[WHEN]` block: one action that triggers the behaviour under test. A test with multiple WHENs — "do A, then do B, then check C" — is two or more tests in disguise. Splitting them gives failure isolation (a failing test points at one action, not an ambiguous sequence) and keeps each test readable as a single, falsifiable claim. A precondition action, such as posting a document so a ledger entry exists to assert against, belongs in `[GIVEN]`; only the action actually being asserted belongs in `[WHEN]`.
## Best Practice
Give each test one `[WHEN]` and one focused claim. A procedure name containing "And" or "Then" in the middle (`GetPrice_AndDiscount_ReturnsValues`) is a strong signal the test should be split.
See sample: [`test-one-when-per-test.good.al`](test-one-when-per-test.good.al).
## Anti Pattern
A test that performs a first action, then a second unrelated action, then asserts on both — mixing two falsifiable claims into one procedure so a failure can't tell you which action broke.
See sample: [`test-one-when-per-test.bad.al`](test-one-when-per-test.bad.al).
## Flow tests — a deliberate exception
A flow test verifies the accumulated outcome of a genuinely multi-round business process (partial receipt then invoicing, several posting rounds against one document), where the sequence itself is the scenario — splitting it would lose the interaction under test. Multiple `[WHEN]` blocks are allowed only when the procedure name declares the flow, each `[WHEN]` is labelled as one round of a single scenario rather than an unrelated action, and the `[THEN]` asserts the accumulated end-state rather than assertions that decompose cleanly per action (if they do decompose cleanly, it is still two tests in disguise). Outside this shape, unit-level tests keep the strict one-WHEN rule.
## Defect-then-fix tests — a second, narrower exception
A test that reproduces a specific broken state and then verifies a subsequent action corrects it is not the same shape as an unrelated-action test, even though its `[THEN]` assertions decompose cleanly per step — clean decomposition is expected here, not a sign of two unrelated tests. This shape is permitted only when the second `[WHEN]` cannot be meaningfully tested without the first (the fix only affects the exact stale state the first action produced, so splitting would just re-run the first action inside a second test's `[GIVEN]`), and the procedure name communicates the before/after relationship.

View file

@ -0,0 +1,27 @@
codeunit 50104 "Item Price Testing"
{
Subtype = Test;
[Test]
procedure ApplyDiscount_LogicTest()
var
Assert: Codeunit "Library Assert";
begin
// logic test — fine on its own, but not paired with a UI test below
Assert.AreEqual(90, ApplyDiscount(100, 10), 'A 10% discount on 100 must yield 90');
end;
local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal
begin
exit(UnitPrice - (UnitPrice * DiscountPct / 100));
end;
[Test]
procedure CustomerCard_Opens_UT()
var
CustomerCard: TestPage "Customer Card";
begin
// UI test mixed into a logic-test codeunit, and the codeunit lacks the _UT suffix
CustomerCard.OpenNew();
end;
}

View file

@ -0,0 +1,42 @@
codeunit 50105 "Item Price Testing"
{
Subtype = Test;
[Test]
procedure ApplyDiscount_ReducesUnitPrice()
var
Assert: Codeunit "Library Assert";
DiscountedPrice: Decimal;
begin
DiscountedPrice := ApplyDiscount(100, 10);
Assert.AreEqual(90, DiscountedPrice, 'A 10% discount on 100 must yield 90');
end;
local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal
begin
exit(UnitPrice - (UnitPrice * DiscountPct / 100));
end;
}
codeunit 50106 "Item Price Testing_UT"
{
Subtype = Test;
[Test]
procedure CustomerCard_SetName_UpdatesField()
var
Customer: Record Customer;
CustomerCard: TestPage "Customer Card";
Assert: Codeunit "Library Assert";
LibrarySales: Codeunit "Library - Sales";
begin
LibrarySales.CreateCustomer(Customer);
CustomerCard.OpenEdit();
CustomerCard.GoToRecord(Customer);
CustomerCard.Name.SetValue('Updated Name');
CustomerCard.Close();
Customer.Get(Customer."No.");
Assert.AreEqual('Updated Name', Customer.Name, 'Name must be updated through the page');
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: testing
keywords: [ui-test, testpage, naming, suffix, codeunit, page-testing, team-convention]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Separate UI-layer and logic-layer tests into different codeunits
## Description
A test codeunit that drives pages through `TestPage` — opening pages, reading FactBox parts, triggering field `OnValidate` through the page — is testing a different layer than a codeunit that calls business-logic procedures directly. Readers need to know which layer a given test exercises without opening it, and a single codeunit that mixes both kinds of test hides that distinction: a failure could mean the logic broke, the page broke, or both. The `_UT` suffix and adjacent-object-ID pairing below are one team's naming convention for making that split visible, not a BCApps-wide naming standard — BCApps itself uses `UT` for unit tests generally, not specifically to mean "UI layer," and does not treat adjacent object IDs as a semantic pairing mechanism. Apply the suffix only on a project that has explicitly adopted this convention.
## Best Practice
Keep UI-layer (`TestPage`-driven) and logic-layer tests in separate codeunits regardless of naming. Projects that adopt a `_UT`-style suffix convention should apply it consistently to every UI-layer test codeunit, keep the corresponding logic-only codeunit unsuffixed, and document the convention where the team's other naming rules live.
See sample: [`ui-test-codeunit-naming.good.al`](ui-test-codeunit-naming.good.al).
## Anti Pattern
One codeunit that mixes a direct logic-call test and a `TestPage`-driven test side by side — a failing test no longer tells a reader which layer actually broke. On a project that has adopted the `_UT` convention, a UI-layer codeunit missing the suffix is also an instance of this anti-pattern; on a project that has not adopted it, the suffix itself is not required.
See sample: [`ui-test-codeunit-naming.bad.al`](ui-test-codeunit-naming.bad.al).

View file

@ -0,0 +1,21 @@
page 50131 "Sample Item List"
{
PageType = List;
SourceTable = "Sample Item";
// Anti-pattern: no CardPageID even though a Card page exists for
// this table, and no UsageCategory, so the page is invisible to
// Tell Me search.
ApplicationArea = All;
layout
{
area(content)
{
repeater(Group)
{
field(Description; Rec.Description) { }
field("No."; Rec."No.") { } // primary key buried, not left-most
}
}
}
}

View file

@ -0,0 +1,20 @@
page 50130 "Sample Item List"
{
PageType = List;
SourceTable = "Sample Item";
CardPageID = "Sample Item Card"; // links back to its Card page
UsageCategory = Lists;
ApplicationArea = All;
layout
{
area(content)
{
repeater(Group)
{
field("No."; Rec."No.") { } // primary key, left-most
field(Description; Rec.Description) { }
}
}
}
}

View file

@ -0,0 +1,90 @@
---
bc-version: [all]
domain: ui
keywords: [pages, page-design, naming-conventions, page-type, card-page, list-page, factbox, worksheet-page, document-page, rolecenter, cardpageid, autosplitkey]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Pages must match one of Business Central's page-type conventions
## Description
Business Central's page types — RoleCenter, Card, List, CardPart,
ListPart, Worksheet, Document, ListPlus, plus system dialog/special
types such as `NavigatePage`, `ConfirmationDialog`, `StandardDialog`,
`HeadlinePart`, and `API` (a selected list of conventional types this
article covers design conventions for — not an exhaustive catalogue of
every current `PageType` value; `PromptDialog`, `ConfigurationDialog`,
`UserControlHost`, and `XmlPort` also exist but follow their own
design rules, out of scope here) — each
fix a naming pattern and a structural constraint, not just a visual
layout. A page whose name, primary-key handling, or linkage
(`CardPageID`, `SubPageLink`, `AutoSplitKey`) doesn't match its own type's
conventions is either the wrong page type for the job or built
inconsistently with the rest of the application, and should be flagged in
review even if it compiles and renders. Before naming a new page or
wiring its links, first ask which page type it is, and whether the source
table actually fits that type's structural requirement — the type fixes
the naming suffix, which fields are visible, and which other page it must
link back to.
## Best Practice
Match the page's design to its type:
- **RoleCenter** — tailored home page for a role; named role + `Role
Center`; links to List pages, shows Cues/Activities.
- **Card** — view/edit one record; named table + `Card`; FastTabs only,
first FastTab named `General`. A single-field primary key is typical,
but not a hard requirement: a subsidiary table that supplements a
master record with its own identity (parent key + own code — Ship-to
Address, Customer/Vendor Bank Account) commonly gets its own Card page
over a composite key too. Treat the key shape as a contextual signal,
not a mandatory constraint — a composite-key table with no such
supplementing relationship to a master record is the actual signal a
List/Worksheet/Tabular page fits better.
- **List** — view multiple records, also the lookup/drilldown surface;
named table + `List` if read-only, or the plural table name if
editable; primary-key fields shown left-most; `CardPageID` must point
at the associated Card page when one exists.
- **CardPart** — single-column FactBox; named for its content +
`FactBox`.
- **ListPart** — multi-column FactBox or subpage (e.g. document lines);
named for its content + `FactBox`/`SubPage`; `SubPageLink` must
actually filter to the host record.
- **Worksheet** — multi-record entry for a Journal-like table, insertion
order preserved; primary-key fields never shown; uses `AutoSplitKey`
with a trailing `Integer` key field.
- **Document** — FastTabs plus a lines subpage, lines filtered to the
header; named for the document (`Sales Invoice`).
- **ListPlus** — like Document but with multiple lists instead of one;
named like the record/report it summarizes.
- System dialog types (`NavigatePage`, `ConfirmationDialog`,
`StandardDialog`, `HeadlinePart`) are fixed shapes with no page-name
suffix convention. `API` pages follow their own property rules and are
extended by adding a new API page, never a page extension.
Before wiring controls, the design step should fix: which users and
tasks the page serves, the concrete fields/commands/links those tasks
need, the page type that matches the content (chosen before the source
table), and the source table that actually holds the page's primary data.
See sample: [`page-design-must-match-bc-page-type-conventions.good.al`](page-design-must-match-bc-page-type-conventions.good.al).
## Anti Pattern
A page that mixes conventions from two types — for example, a "List"
page with no `CardPageID` even though a Card page exists for the same
table — signals a design step was skipped, not a stylistic choice. A
Card page over a composite-key table is not automatically this anti
pattern; check whether the table supplements a master record first. Also watch
for a Worksheet or List page showing primary-key fields it shouldn't (or
hiding them when it should show them). A page with no `UsageCategory` set
is not automatically a defect either: supporting pages, subpages, dialogs,
and pages intended only to be reached through another workflow correctly
have no `UsageCategory` — flag its absence only on a page intended as a
searchable entry point in its own right.
See sample: [`page-design-must-match-bc-page-type-conventions.bad.al`](page-design-must-match-bc-page-type-conventions.bad.al).

View file

@ -0,0 +1,32 @@
local procedure UpgradeCustomerFields()
begin
if not UpgradeTag.HasUpgradeTag(GetCustomerDiscountFieldTag()) then begin
Customer.SetLoadFields("Discount %", "Customer Posting Group");
if Customer.FindSet() then
repeat
if (Customer."Discount %" = 0) and (Customer."Customer Posting Group" <> '') then begin
Customer."Discount %" := 5;
Customer.Modify();
end;
until Customer.Next() = 0;
UpgradeTag.SetUpgradeTag(GetCustomerDiscountFieldTag());
// BUG: a second, unrelated migration's tag check nested inside the
// first migration's guarded body. Neither tag can be checked,
// skipped, or fixed independently of the other - a failure or a
// deliberate skip of the discount migration silently takes the
// shipping-agent migration down with it, and nothing in the
// Upgrade Tags table records that the second step ran on its own.
if not UpgradeTag.HasUpgradeTag(GetCustomerShippingAgentFieldTag()) then begin
Customer.SetLoadFields("Shipping Agent Code");
if Customer.FindSet() then
repeat
if Customer."Shipping Agent Code" = '' then begin
Customer."Shipping Agent Code" := DefaultShippingAgentCode();
Customer.Modify();
end;
until Customer.Next() = 0;
UpgradeTag.SetUpgradeTag(GetCustomerShippingAgentFieldTag());
end;
end;
end;

View file

@ -0,0 +1,40 @@
local procedure UpgradeCustomerDiscountField()
begin
if UpgradeTag.HasUpgradeTag(GetCustomerDiscountFieldTag()) then
exit;
Customer.SetLoadFields("Discount %", "Customer Posting Group");
if Customer.FindSet() then
repeat
// A business-data safety condition inside this one migration's
// loop is not a second migration hiding inside the first -
// Microsoft's own upgrade-tag example nests exactly this shape
// (a corruption guard, then a redundant-write guard) inside a
// single tagged procedure.
if (Customer."Discount %" = 0) and (Customer."Customer Posting Group" <> '') then begin
Customer."Discount %" := 5;
Customer.Modify();
end;
until Customer.Next() = 0;
UpgradeTag.SetUpgradeTag(GetCustomerDiscountFieldTag());
end;
// A second, genuinely unrelated migration gets its own tag and its own
// top-level procedure - not nested inside the first one's guarded body.
local procedure UpgradeCustomerShippingAgentField()
begin
if UpgradeTag.HasUpgradeTag(GetCustomerShippingAgentFieldTag()) then
exit;
Customer.SetLoadFields("Shipping Agent Code");
if Customer.FindSet() then
repeat
if Customer."Shipping Agent Code" = '' then begin
Customer."Shipping Agent Code" := DefaultShippingAgentCode();
Customer.Modify();
end;
until Customer.Next() = 0;
UpgradeTag.SetUpgradeTag(GetCustomerShippingAgentFieldTag());
end;

View file

@ -0,0 +1,34 @@
---
bc-version: [all]
domain: upgrade
keywords: [upgrade-tag, nesting, complexity, upgrade-per-company, upgrade-per-database]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Never nest upgrade tag checks or blend two migrations under one tag
## Description
Upgrade tag *checks* should stay flat: never nest one tag's existence check inside another tag's guarded body, and never let one tagged procedure quietly perform a second, functionally distinct migration — that turns two upgrade steps into one that can't be tracked, skipped, or fixed independently, which is exactly what separate tags exist to prevent. That is the specific nesting Microsoft's own guidance warns against ("Keep tags simple by limiting nesting tags to two levels").
That is not a limit on how much conditional logic a single migration's own loop body may contain. Microsoft's own worked example for upgrade tags nests a record loop with two business-data safety conditions — a corruption guard, then a redundant-write guard — inside one `if UpgradeTagMgt.HasUpgradeTag(...) then exit;`-guarded procedure, and its own design guidance separately *requires* this: "Implement extra safety checks to avoid data corruption, even though you're using upgrade tags." A business-data guard that protects the single migration a tag represents is not a second migration hiding inside the first, however many `if` levels it takes.
Upgrade code runs unattended, once, against production data with no chance to interactively debug a wrong branch — which is why mixing two migrations under one tag, or losing track of which tag guards which step, is a genuinely higher-cost mistake here than the equivalent would be in ordinary application code.
## Best Practice
One tag, one migration: exit early if the tag is already set, then run the one upgrade step that tag represents — including as many business-data safety conditions as that single step's own correctness requires, nested however deep the logic actually needs. Reach for a second, separately tagged migration only when the nested logic is doing genuinely unrelated work (a different table, a different field, a different concern) that could legitimately be skipped, retried, or fixed on its own.
See sample: [`upgrade-tag-logic-must-not-nest-deeply.good.al`](upgrade-tag-logic-must-not-nest-deeply.good.al).
## Anti Pattern
Checking one upgrade tag inside the guarded body of another, or writing two functionally unrelated migrations — different tables, different concerns — under a single tag so neither can be tracked, skipped, or fixed independently of the other. A record loop with business-data safety conditions inside one tagged migration's own body is not this anti-pattern, even several `if` levels deep, as long as every condition serves that one migration.
See sample: [`upgrade-tag-logic-must-not-nest-deeply.bad.al`](upgrade-tag-logic-must-not-nest-deeply.bad.al).
## Source
Microsoft's own "Upgrading Extensions" guidance, Design considerations: "Keep tags simple by limiting nesting tags to two levels. Complicated if statements can lead to problems." — https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-upgrading-extensions#using-upgrade-tags-to-control-upgrade-code

View file

@ -0,0 +1,25 @@
page 50100 "Vendor Document API"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'documents';
APIVersion = 'v1.0';
EntityName = 'vendorDocument';
EntitySetName = 'vendorDocuments';
SourceTable = Vendor;
// no InsertAllowed/ModifyAllowed override, no Editable = false anywhere
layout
{
area(content)
{
repeater(GroupName)
{
field(no; Rec."No.") { }
field(vatRegNo; Rec."VAT Registration No.") { }
field(contactEmail; Rec."E-Mail") { }
// ...dozens more fields, none marked Editable = false
}
}
}
}

View file

@ -0,0 +1,25 @@
page 50102 "Vendor Contact Info API"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'integration';
APIVersion = 'v1.0';
EntityName = 'vendorContact';
EntitySetName = 'vendorContacts';
SourceTable = Vendor;
DelayedInsert = true;
InsertAllowed = false;
DeleteAllowed = false;
layout
{
area(content)
{
repeater(GroupName)
{
field(no; Rec."No.") { Editable = false; }
field(contactEmail; Rec."E-Mail") { }
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: web-services
keywords: [api-page, least-privilege, write-access, odata, security, external-api, identity-fields]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Give API pages least-privilege write access
## Description
A general-purpose API page that exposes many fields should not be widened to allow writes on one additional field. A `PageType = API` page consumed by an external integration, an automation agent, or a partner system carries the same risk regardless of caller: a write-enabled page with no per-field restriction is a wide-open surface. Least privilege has to cover both dimensions of exposure: which fields are on the page, and which operations the page allows. Only a field actually placed on the page is reachable at all — but a page that includes many fields, with `InsertAllowed`/`ModifyAllowed`/`DeleteAllowed` left at their defaults and no `Editable = false` on most of them, leaves every one of those included fields — identity fields and financially significant ones among them — fully writable, with nothing marking that as deliberate. Restricting fields alone is not enough either: a page with only two fields on it can still let a caller insert brand-new records or delete existing ones if `InsertAllowed`/`DeleteAllowed` are left at their true defaults (both `true`).
## Best Practice
Create a separate, minimal API page that exposes only the key and the specific field the consumer needs to write, with everything else `Editable = false` or simply absent from the page — and set `InsertAllowed`/`DeleteAllowed` to `false` unless the consumer's use case genuinely needs to create or delete records through that page.
See sample: [`api-page-least-privilege-write-access.good.al`](api-page-least-privilege-write-access.good.al).
## Anti Pattern
Widening an existing general-purpose API page with write access to one field, leaving every other field on the page (including identity and posting fields) writable by default because no one added `Editable = false`.
See sample: [`api-page-least-privilege-write-access.bad.al`](api-page-least-privilege-write-access.bad.al).