mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
Add 18 community AL/BC patterns across style, data-modeling, web-services, appsource, breaking-changes, performance, and testing
Contributed by CURABIS ApS, generalized from patterns observed across real AppSource/PTE development. Each article follows the knowledge file format (frontmatter, Description/Best Practice/Anti Pattern, sibling .good.al/.bad.al samples).
This commit is contained in:
parent
07e324ddbc
commit
057e17c202
52 changed files with 1109 additions and 0 deletions
|
|
@ -0,0 +1,22 @@
|
|||
codeunit 50101 "Meter Jnl.-Post"
|
||||
{
|
||||
procedure Post(var MeterJnlLine: Record "Meter Journal Line")
|
||||
var
|
||||
MeterLedgEntry: Record "Meter Ledger Entry";
|
||||
begin
|
||||
// Validation, Journal access, and posting all mixed in one routine.
|
||||
if not Confirm('Post journal lines?') then
|
||||
exit;
|
||||
|
||||
if MeterJnlLine.FindSet() then
|
||||
repeat
|
||||
if MeterJnlLine.Quantity = 0 then
|
||||
Error('Quantity must not be zero.');
|
||||
|
||||
MeterLedgEntry.Init();
|
||||
MeterLedgEntry.TransferFields(MeterJnlLine);
|
||||
MeterLedgEntry.Insert();
|
||||
MeterJnlLine.Delete();
|
||||
until MeterJnlLine.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
codeunit 50101 "Meter Jnl.-Post Line"
|
||||
{
|
||||
procedure PostLine(var MeterJnlLine: Record "Meter Journal Line")
|
||||
var
|
||||
MeterLedgEntry: Record "Meter Ledger Entry";
|
||||
begin
|
||||
// Writes exactly one ledger entry; never touches the Journal table.
|
||||
MeterLedgEntry.Init();
|
||||
MeterLedgEntry.TransferFields(MeterJnlLine);
|
||||
MeterLedgEntry.Insert();
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50102 "Meter Jnl.-Post Batch"
|
||||
{
|
||||
procedure PostBatch(var MeterJnlLine: Record "Meter Journal Line")
|
||||
var
|
||||
CheckLine: Codeunit "Meter Jnl.-Check Line";
|
||||
PostLine: Codeunit "Meter Jnl.-Post Line";
|
||||
begin
|
||||
if MeterJnlLine.FindSet() then
|
||||
repeat
|
||||
CheckLine.CheckLine(MeterJnlLine);
|
||||
until MeterJnlLine.Next() = 0;
|
||||
|
||||
if MeterJnlLine.FindSet() then
|
||||
repeat
|
||||
PostLine.PostLine(MeterJnlLine);
|
||||
MeterJnlLine.Delete();
|
||||
until MeterJnlLine.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [posting-routine, check-line, post-line, post-batch, companion-codeunit, yes-no-wrapper, journal]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Split posting routines into Check Line / Post Line / Post Batch
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Every journal-based posting routine in Business Central is split across three companion codeunits with distinct, non-overlapping responsibilities: `Check Line` validates one line, `Post Line` writes exactly one line to the ledger, and `Post Batch` loops both across the journal. A document posting routine (posting one document at a time) calls `Post Line` directly and skips `Post Batch`. A new posting routine that blurs this split either misses functionality other code expects to call directly, or exposes an interaction surface it shouldn't.
|
||||
|
||||
## Best Practice
|
||||
|
||||
`Check Line` reads setup/dimension data only on its first call and shows no UI beyond errors. `Post Line` only operates on the record passed to it — never the Journal table — so it can be called directly by other posting code, including a document posting routine. `Post Batch` is the only one of the three that reads and updates the Journal table, and it is the only one invoked from the Post action on a journal page. A `-Post` document codeunit is never called directly from a page; a page calls a `-Post (Yes/No)` confirmation wrapper instead, so the same `-Post` codeunit can also run unattended from a batch-posting report.
|
||||
|
||||
See sample: `check-post-line-batch-pattern.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A single monolithic posting codeunit that reads the Journal table, validates lines, writes ledger entries, and shows confirmation dialogs all in one procedure. It cannot be reused by another posting routine without fabricating journal records, and it cannot run unattended because it insists on user interaction.
|
||||
|
||||
See sample: `check-post-line-batch-pattern.bad.al`.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
table 50100 "Course"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(10; "Global Dimension 1 Code"; Code[20])
|
||||
{
|
||||
// No CaptionClass, no OnValidate call into DimensionManagement.
|
||||
// Accepts any value; never becomes a Default Dimension record.
|
||||
TableRelation = "Dimension Value".Code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
table 50100 "Course"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(10; "Global Dimension 1 Code"; Code[20])
|
||||
{
|
||||
CaptionClass = '1,1,1';
|
||||
TableRelation = "Dimension Value".Code where(
|
||||
"Global Dimension No." = const(1), Blocked = const(false));
|
||||
|
||||
trigger OnValidate()
|
||||
var
|
||||
DimMgt: Codeunit DimensionManagement;
|
||||
begin
|
||||
DimMgt.ValidateShortcutDimCode(1, "Global Dimension 1 Code");
|
||||
DimMgt.SaveDefaultDim(Database::Course, "No.", FieldNo("Global Dimension 1 Code"), "Global Dimension 1 Code");
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnDelete()
|
||||
var
|
||||
DimMgt: Codeunit DimensionManagement;
|
||||
begin
|
||||
DimMgt.DeleteDefaultDim(Database::Course, "No.");
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [dimensions, dimensionmanagement, global-dimension, shortcut-dimension, default-dimension, validateshortcutdimcode, createdim]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Wire dimension support through DimensionManagement, not ad hoc fields
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Adding dimension support to a custom master or document table is not just a matter of adding a `Code[20]` field. Business Central expects a specific set of hooks into `Codeunit "Dimension Management"` so a dimension value is validated, persisted as a Default Dimension record, and flows through to transactions the same way it does for every standard table. Skipping any one hook produces a field that looks correct in the designer but silently fails to save, validate, or carry through to postings.
|
||||
|
||||
## Best Practice
|
||||
|
||||
A master table should validate its dimension fields through `ValidateShortcutDimCode` and `SaveDefaultDim`, and create/delete the matching Default Dimension records in `OnInsert`/`OnDelete`. A document table should add Shortcut Dimension fields validated the same way, and call `CreateDim` to pull dimension values from the related master record whenever the field that attaches the document to that master changes.
|
||||
|
||||
See sample: `dimension-management-wiring.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding a dimension-looking field with only a `TableRelation` to Dimension Value, and no call into `DimensionManagement` at all. The field accepts input but never becomes a real Default Dimension record, so it does not validate against blocked values and does not flow into postings.
|
||||
|
||||
See sample: `dimension-management-wiring.bad.al`.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [primary-key, clustered-key, table-design, appsource, breaking-change, schema-upgrade]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Never Change a Published Table's Primary or Clustered Key Field List
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Once a table has shipped — to AppSource, or to any customer environment that has already upgraded onto it — its primary key, and any other key marked `Clustered = true`, is frozen. This includes adding a field to the key, not only removing or reordering one: Business Central identifies existing rows by their key value, so any change to which fields compose that key invalidates every row already stored under the old shape, and the platform's upgrade validation rejects it outright (`AS0009`). This is easy to trip over because it doesn't look like the well-known "don't delete a field" mistake — the field being added is often brand new, and folding a new discriminating dimension straight into the existing key feels like the natural, un-denormalized way to model it. On an unpublished table that is correct; on a published one it is a breaking schema change regardless of which direction the field list changed, and there is no in-place fix once the upgrade is rejected, only reverting the key to its published shape.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Leave a published table's key exactly as shipped. Model a new discriminating dimension as a separate table with its own key instead of adding a field to the existing key, and branch orchestration code by the new dimension rather than filtering one shared table on an extra key field.
|
||||
|
||||
See sample: `do-not-change-primary-key.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding a field to a published table's primary or clustered key to distinguish a new case. This fails AppSource validation or any customer upgrade with `AS0009` as soon as rows already exist under the old key shape, whether the field is being added, removed, or reordered.
|
||||
|
||||
See sample: `do-not-change-primary-key.bad.al`.
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
table 50100 "Period Stats"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Period Start"; Date) { }
|
||||
field(2; Flow; Enum "Some Flow") { }
|
||||
}
|
||||
keys
|
||||
{
|
||||
// Table already shipped with key(PK; "Period Start").
|
||||
// Adding Flow here breaks every upgrade with AS0009.
|
||||
key(PK; Flow, "Period Start") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
table 50100 "Period Stats"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Period Start"; Date) { }
|
||||
}
|
||||
keys
|
||||
{
|
||||
key(PK; "Period Start") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
table 50101 "Period Stats By Flow"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Period Start"; Date) { }
|
||||
field(2; Flow; Enum "Some Flow") { }
|
||||
}
|
||||
keys
|
||||
{
|
||||
key(PK; "Period Start") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [testfield, setup-table, configuration, mandatory-field, silent-fallback, correctness]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# TestField a Setup-Table Value Before Using It in a Correctness-Critical Branch
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
A procedure that reads a field from a setup or configuration table inside a branch where the surrounding logic has already decided that value is required needs to guard against it being blank. A common anti-pattern silently treats "blank" as "feature not wanted": it checks the field for emptiness and falls through to a default instead of raising an error. `Get()` succeeding on the setup record only proves the record exists, not that the specific field was ever configured, so the fallback path makes a missing configuration indistinguishable from a deliberate one — and the wrong outcome, especially in financial, tax, or compliance postings, surfaces silently rather than as a crash a tester would notice.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Once a business rule has decided that a setup-table field's value is required for a branch to behave correctly, call `TestField` on it before use, even though a plain read would "work" by returning a blank or zero without erroring. Write a test that blanks the setup field and asserts the resulting error, so the guard itself is verified rather than merely present.
|
||||
|
||||
See sample: `testfield-required-setup-field.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Reading a required setup-table field behind a presence check that falls through to a default value instead of erroring. This looks defensive because it never crashes, but it converts "administrator forgot to configure this" into "system silently did something else" — worse than a hard failure, because nobody is told anything went wrong.
|
||||
|
||||
See sample: `testfield-required-setup-field.bad.al`.
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50100 "Tax Posting Helper"
|
||||
{
|
||||
procedure GetTaxAccount(var Setup: Record "Sales & Receivables Setup"): Code[20]
|
||||
var
|
||||
DefaultTaxAccountTxt: Label 'DEFAULT-TAX';
|
||||
begin
|
||||
Setup.Get();
|
||||
if Setup."Tax Account No." <> '' then
|
||||
exit(Setup."Tax Account No.");
|
||||
exit(DefaultTaxAccountTxt);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
codeunit 50100 "Tax Posting Helper"
|
||||
{
|
||||
procedure GetTaxAccount(var Setup: Record "Sales & Receivables Setup"): Code[20]
|
||||
begin
|
||||
Setup.Get();
|
||||
Setup.TestField("Tax Account No.");
|
||||
exit(Setup."Tax Account No.");
|
||||
end;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue