AppSource-Compliant Extension Engineering in Microsoft Dynamics 365 Business Central
Click for Full Screen
Figure 1: Shipment Notes & Sales Control Extension Overview in Microsoft Dynamics 365 Business Central v24 CloudView GitHub Source Code →

What AppSource compliance requires

AppSourceCop is the code analyser built into the AL Language extension for Visual Studio Code. It enforces the mandatory naming affix, the object ID range assigned to you, obsolete-state discipline and breaking-change protection — and an extension that does not satisfy it will not be published.

This guide takes apart a real extension rather than describing a pattern in the abstract. Shipment Notes & Sales Control solves two ordinary problems: logistics teams need shipment tracking that does not depend on posting, and finance needs a way to stop an incomplete sales order from posting at all. The source is on GitHub, linked above.

The engineering decisions are the point. Where validation lives, how posting is intercepted without touching base objects, how errors stay translatable, and what the tests assert — each of those is a choice that either survives the next Business Central update or does not.


Key Business Value & Features

CapabilityBusiness ImpactTechnical Implementation
Advanced Logistics TrackingIndependent shipment note lifecycle (Draft Pending Approved Released).Custom XYZ Shipment Note entity with table-level state transition rules.
Sales Order Quality ControlPrevents accidental posting of unverified sales orders.Event subscriber listening to Sales-Post.OnBeforePostSalesDoc.
Automated Document SeriesAuto-generates clean, sequential document numbers.Integrated with standard Codeunit "No. Series".
Enterprise ReadinessZero compiler warnings, 100% AppSource compliance.Strict affix matching (XYZ), assigned ID ranges (70000000+), and XLIFF localization.

1. AppSource Compliance & Metadata Configuration

Building cloud-ready extensions for Microsoft AppSource requires strict adherence to code analysis rules, object ID allocations, and naming conventions.

  • Assigned ID Range: 70000000 to 70000999
  • Mandatory Affix: XYZ (enforced via AppSourceCop.json)
  • Target Baseline: Cloud (BC 24.0+)
  • Localization Feature: Enabled TranslationFile in app.json for XLIFF translation generation.

AppSourceCop.json

AppSourceCop.json
{
  "mandatoryAffixes": [
    "XYZ"
  ]
}

app.json (Excerpt)

app.json
{
  "id": "5f87e36f-324b-410f-8acb-2568802267e5",
  "name": "Shipment Notes & Sales Control",
  "publisher": "Data Daur",
  "version": "1.0.1.0",
  "platform": "24.0.0.0",
  "application": "24.0.0.0",
  "target": "Cloud",
  "idRanges": [
    {
      "from": 70000000,
      "to": 70000999
    }
  ],
  "runtime": "17.0",
  "features": [
    "NoImplicitWith",
    "TranslationFile"
  ]
}

2. Feature Deep Dive: Advanced Shipment Notes & State Validation

Logistics teams require structured document lifecycles to prevent premature release of warehouse instructions. The extension introduces the XYZ Shipment Note table (70000001) paired with the XYZ Shipment Note Status Enum (70000000).

Status Enum Definition (XYZShipmentNoteStatus.Enum.al)

XYZShipmentNoteStatus.Enum.al
enum 70000000 "XYZ Shipment Note Status"
{
    Extensible = true;
    
    value(0; "Draft") { Caption = 'Draft'; }
    value(1; "Pending") { Caption = 'Pending'; }
    value(2; "Approved") { Caption = 'Approved'; }
    value(3; "Released") { Caption = 'Released'; }
}

State Transition Rule Enforcement (XYZShipmentNote.Table.al)

To prevent users from bypassing review stages (e.g., jumping directly from Draft to Released), validation logic is embedded directly within the field’s OnValidate() trigger:

XYZShipmentNote.Table.al
table 70000001 "XYZ Shipment Note"
{
    Caption = 'XYZ Shipment Note';
    DataClassification = CustomerContent;

    fields
    {
        field(1; "No."; Code[20]) { Caption = 'No.'; DataClassification = CustomerContent; }
        field(2; "Description"; Text[100]) { Caption = 'Description'; DataClassification = CustomerContent; }
        field(3; "Status"; Enum "XYZ Shipment Note Status")
        {
            Caption = 'Status';
            DataClassification = CustomerContent;

            trigger OnValidate()
            begin
                // Prevent invalid state transition from Draft directly to Released
                if (xRec.Status = Status::Draft) and (Status = Status::Released) then
                    Error(InvalidStatusTransitionErr, xRec.Status, Status);
            end;
        }
        field(4; "Posting Date"; Date) { Caption = 'Posting Date'; DataClassification = CustomerContent; }
        field(5; "Sales Header No."; Code[20])
        {
            Caption = 'Sales Header No.';
            DataClassification = CustomerContent;
            TableRelation = "Sales Header"."No." where("Document Type" = const(Order));
        }
        field(6; "No. Series"; Code[20])
        {
            Caption = 'No. Series';
            DataClassification = CustomerContent;
            TableRelation = "No. Series";
        }
    }

    trigger OnInsert()
    var
        XYZSetup: Record "XYZ Setup";
        NoSeries: Codeunit "No. Series";
    begin
        if "No." = '' then begin
            XYZSetup.Get();
            XYZSetup.TestField("XYZ Shipment Note Nos.");
            "No. Series" := XYZSetup."XYZ Shipment Note Nos.";
            "No." := NoSeries.GetNextNo("No. Series");
        end;
    end;

    var
        InvalidStatusTransitionErr: Label 'You cannot change the status directly from %1 to %2.', Comment = '%1 = Old Status, %2 = New Status';
}

3. Feature Deep Dive: Event-Driven Sales Posting Control

Instead of modifying standard Business Central source code, modern AL development relies on event subscribers.

The extension extends the standard Sales Header table and Sales Order page to introduce a manual XYZ Block Posting switch. When enabled, an event subscriber intercepts Sales-Post execution.

Table & Page Extensions

XYZSalesHeader.TableExt.al & XYZSalesOrder.PageExt.al
// TableExtension: XYZSalesHeader.TableExt.al
tableextension 70000000 "XYZ Sales Header Ext" extends "Sales Header"
{
    fields
    {
        field(70000000; "XYZ Block Posting"; Boolean)
        {
            Caption = 'XYZ Block Posting';
            DataClassification = CustomerContent;
        }
    }
}

// PageExtension: XYZSalesOrder.PageExt.al
pageextension 70000000 "XYZ Sales Order Ext" extends "Sales Order"
{
    layout
    {
        addlast(General)
        {
            field("XYZ Block Posting"; Rec."XYZ Block Posting")
            {
                ApplicationArea = All;
                ToolTip = 'Specifies if posting is blocked for this Sales Order.';
            }
        }
    }
}

Event Subscriber (XYZSalesPostingEventSub.Codeunit.al)

XYZSalesPostingEventSub.Codeunit.al
codeunit 70000000 "XYZ Sales Posting Event Sub"
{
    [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnBeforePostSalesDoc', '', false, false)]
    local procedure OnBeforePostSalesDoc(var SalesHeader: Record "Sales Header"; CommitIsSuppressed: Boolean; PreviewMode: Boolean)
    begin
        if SalesHeader."XYZ Block Posting" then
            Error(PostingBlockedErr, SalesHeader."No.");
    end;

    var
        PostingBlockedErr: Label 'Posting is blocked for Sales Order %1.', Comment = '%1 = Sales Order No.';
}

4. Enterprise Security & Translatable Localization

Explicit Permission Set (XYZPermissions.permissionset.al)

To adhere to modern Business Central security guidelines, an explicit Assignable = true permission set grants controlled RMID and execution rights across all custom objects:

XYZPermissions.permissionset.al
permissionset 70000000 XYZPermissions
{
    Assignable = true;
    Permissions = tabledata "XYZ Setup" = RMID,
        tabledata "XYZ Shipment Note" = RMID,
        table "XYZ Setup" = X,
        table "XYZ Shipment Note" = X,
        codeunit "XYZ Sales Posting Event Sub" = X,
        codeunit "XYZ Test Sales Block" = X,
        page "XYZ Setup Card" = X,
        page "XYZ Shipment Note List" = X,
        page "XYZ Shipment Note Card" = X;
}

Translatable Labels & XLIFF Localization

All hardcoded messages were converted into AL Label variables containing translator comments, ensuring clean auto-generation of XLIFF translation manifests (Shipment Notes & Sales Control.g.xlf).


5. Automated Unit Testing & Robust Assertions

Relying on empty asserterror blocks in automated test suites can lead to false positives (e.g., catching unrelated setup errors).

Data Daur built a dedicated assertion helper codeunit (XYZLibraryAssert.Codeunit.al) and an automated test suite (XYZTestSalesBlock.Codeunit.al).

XYZTestSalesBlock.Codeunit.al
codeunit 70000040 "XYZ Test Sales Block"
{
    Subtype = Test;

    [Test]
    procedure TestPostingBlockedWhenXYZBlockPostingIsTrue()
    var
        SalesHeader: Record "Sales Header";
        SalesPost: Codeunit "Sales-Post";
        LibraryAssert: Codeunit "Library Assert";
    begin
        // [Given] A Sales Order with XYZ Block Posting = TRUE
        Initialize();
        CreateSalesOrder(SalesHeader);
        SalesHeader."XYZ Block Posting" := true;
        SalesHeader.Modify();

        // [When] Posting is attempted
        // [Then] Verify that the error is caught using asserterror and matches explicitly
        asserterror SalesPost.Run(SalesHeader);
        
        LibraryAssert.ExpectedError(StrSubstNo(PostingBlockedErr, SalesHeader."No."));
    end;

    var
        PostingBlockedErr: Label 'Posting is blocked for Sales Order %1.', Comment = '%1 = Sales Order No.';
}

End-to-End Operational User Guide

Below is the step-by-step procedure for administrators and logistics coordinators to configure and utilize the extension in Business Central.

Step 1: Initial Setup & Number Series Assignment

  1. Open Business Central and press Alt + Q (Tell Me search).
  2. Type XYZ Setup and open the XYZ Setup Card.
  3. Under the General FastTab, locate XYZ Shipment Note Nos..
  4. Select or create a new Number Series (e.g., Code: XYZ-SHIP, Starting No.: SHP-0001, Ending No.: SHP-9999, Default Nos.: Yes).
  5. Assign XYZ-SHIP to the setup field and close the page.
XYZ Setup Card Configuration
Full Screen
Figure 2: Configuring XYZ Setup Card in Business Central
Creating Number Series XYZ-SHIP
Full Screen
Figure 3: Creating Sequential Number Series (XYZ-SHIP)

Step 2: Managing XYZ Shipment Notes

  1. Press Alt + Q and search for XYZ Shipment Notes.
  2. Click New to open the XYZ Shipment Note Card.
  3. The No. field automatically populates from your configured number series (SHP-0001).
  4. Select a related Sales Order No., enter a description, and select a posting date.
  5. State Transition Test: Attempt to change the Status from Draft directly to Released. The system will throw the translatable error:
    "You cannot change the status directly from Draft to Released."
  6. Transition sequentially through Draft Pending Approved Released.
XYZ Shipment Notes List View
Full Screen
Figure 4: XYZ Shipment Notes List View
XYZ Shipment Note Card State Transition Test
Full Screen
Figure 5: State Validation Interception on Shipment Note Card

Step 3: Enforcing & Testing Sales Order Posting Interception

  1. Navigate to Sales Orders and open an existing active order.
  2. Under the General FastTab, locate XYZ Block Posting and switch it to TRUE.
  3. Click Posting Post... (F9).
  4. Select Ship and Invoice and press OK.
  5. Result: The posting process is safely intercepted, presenting a clear error notification:
    "Posting is blocked for Sales Order 101005."
  6. Toggle XYZ Block Posting off (FALSE) to resume normal posting capabilities.
Sales Order Interception Error Verification
Full Screen
Figure 6: Posting Interception Error Notification on Sales Order 101005

Conclusion & Key Developer Takeaways

Building extensions for Dynamics 365 Business Central Cloud demands a discipline of strict governance:

  1. Always use AppSourceCop: Enforce mandatory affixes (XYZ) and clean ID allocations (70000000+).
  2. Never hack standard objects: Utilize clean event subscriptions (OnBeforePostSalesDoc) to extend standard behaviors safely.
  3. Enforce business logic at the table layer: Embedding state machine rules in OnValidate() ensures rules are honored regardless of which UI page or API triggers the edit.
  4. Assert explicit error messages in tests: Avoid generic asserterror statements; always verify GetLastErrorText() matches expected labels.

About Data Daur

Data Daur specializes in enterprise Dynamics 365 Business Central development, AppSource extension engineering, and cloud architecture solutions.