Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id is the app's stable identity; version identifies 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.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not an explicit fallback: when Customer.Get(...) is false, no assignment occurs, so CustomerHomePage retains its prior/default value. That contradicts the article's own requirement for an explicit, deliberately chosen fallback. Add an else assignment (or clear/initialize the value explicitly before the lookup) so the good fixture demonstrates the stated rule.

// 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;
}
Loading