-
Notifications
You must be signed in to change notification settings - Fork 121
18 more AL/BC patterns: data-modeling, testing, style, security, error-handling, ui, upgrade, web-services, appsource #157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 version number is the only identity a deployed app has. 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. | ||
|
|
||
| ## 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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| table 50111 "Sample Item Card" | ||
| { | ||
| fields | ||
| { | ||
| field(1; "No."; Code[20]) { } | ||
| field(50; Picture; BLOB) | ||
| { | ||
| Caption = 'Picture'; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| table 50110 "Sample Item Card" | ||
| { | ||
| fields | ||
| { | ||
| field(1; "No."; Code[20]) { } | ||
| field(50; Picture; Media) | ||
| { | ||
| Caption = 'Picture'; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. | ||
|
|
||
| ## 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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| --- | ||
| 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. | ||
|
|
||
| See sample: `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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // Low blast radius: guard, with an explicit chosen fallback. | ||
| if Customer.Get(SalesHeader."Sell-to Customer No.") then | ||
| CustomerHomePage := Customer."Home Page"; | ||
| // Blank is an acceptable, deliberately-considered default here - the field | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not an explicit fallback: when |
||
| // is purely a display convenience and a reviewer sees it before the document ships. | ||
|
|
||
| // 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"; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. | ||
|
|
||
| ## 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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
idis the app's stable identity;versionidentifies a release of that app. Calling the version “the only identity” is factually wrong and can misteach manifest semantics. Please reword this to say that the version distinguishes deployed code states/releases, while retaining the valid warning about different code sharing one version.