AL Extension Development Best Practices for Business Central

Events over modifications, naming and affixes, performance with SetLoadFields, analyzers, telemetry, automated tests and CI/CD for upgrade-safe extensions.

AL extensions are how you customise Microsoft Dynamics 365 Business Central. Getting one to compile is easy. Getting one that survives years of Business Central updates, performs well with large data volumes and can be supported by other developers takes some discipline.

These are the practices I follow on every Business Central project, whether it's a small per-tenant extension or a large solution.

1. Extend, don't modify: design around events

In Business Central you can't change standard objects. You extend them with table extensions, page extensions, report extensions and event subscribers. This is what makes the platform upgradeable, so lean into it.

enum 50100 "DS Loyalty Tier"
{
    Extensible = true;

    value(0; Standard) { Caption = 'Standard'; }
    value(1; Silver) { Caption = 'Silver'; }
    value(2; Gold) { Caption = 'Gold'; }
}

codeunit 50110 "DS Sales Post Subscribers"
{
    [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnBeforePostSalesDoc', '', false, false)]
    local procedure CheckLoyaltyTierOnBeforePost(var SalesHeader: Record "Sales Header")
    var
        Customer: Record Customer;
        MissingTierErr: Label 'Customer %1 must have a loyalty tier before posting.', Comment = '%1 = Customer No.';
    begin
        if SalesHeader."Document Type" <> SalesHeader."Document Type"::Order then
            exit;

        Customer.SetLoadFields("DS Loyalty Tier");
        Customer.Get(SalesHeader."Sell-to Customer No.");
        if Customer."DS Loyalty Tier" = Enum::"DS Loyalty Tier"::Standard then
            Error(MissingTierErr, Customer."No.");
    end;
}
  • Subscribers only need to declare the parameters they actually use.
  • Be careful with IsHandled events. Skipping standard code is powerful, but it also means you're responsible for everything the standard code did.
  • Publish your own integration events so other extensions, including your own future ones, can extend you too.
  • Use enums and interfaces instead of hard-coded case statements when behaviour needs to vary.

2. Naming, affixes and object IDs

Every object and every field you add to a standard table should carry a prefix or suffix (for example DS), so it can't collide with Microsoft's objects or other apps. AppSource requires it, and it's good practice for per-tenant extensions too. Keep object IDs inside the ranges declared in app.json:

{
  "id": "6f1c3a52-2c7e-4c0e-9d0a-1f5b2c3d4e5f",
  "name": "DS Loyalty",
  "publisher": "Diwas Shrestha",
  "version": "1.0.0.0",
  "brief": "Customer loyalty tiers for Business Central",
  "platform": "1.0.0.0",
  "application": "26.0.0.0",
  "runtime": "15.0",
  "idRanges": [{ "from": 50100, "to": 50149 }],
  "features": ["NoImplicitWith", "TranslationFile"],
  "applicationInsightsConnectionString": "InstrumentationKey=…;IngestionEndpoint=…"
}

Set application to the lowest Business Central version you support, not just the one on your machine.

3. Write performance-aware AL

Most slow Business Central customisations come down to a handful of patterns:

  • Use SetLoadFields (partial records) so the database only reads the columns you use. This matters a lot on wide tables like Customer, Item and Sales Line.
  • Filter before you loop. Use SetRange and SetFilter on indexed fields, and add keys for your common filters.
  • Pick the right read. Use FindSet for loops, FindFirst or Get for single records, IsEmpty for existence checks, and Count, CalcSums or ModifyAll instead of manual loops.
  • Avoid Commit inside loops and long-running transactions that lock tables for other users.
  • Move heavy work to the background with job queue entries, TaskScheduler or page background tasks.
local procedure NotifyGoldCustomers()
var
    Customer: Record Customer;
begin
    Customer.SetLoadFields("No.", Name, "E-Mail");
    Customer.SetRange("DS Loyalty Tier", Enum::"DS Loyalty Tier"::Gold);
    if Customer.FindSet() then
        repeat
            SendLoyaltyMail(Customer);
        until Customer.Next() = 0;
end;

4. Turn on the code analyzers

The AL compiler ships with analyzers that catch problems before your users do. Enable them in .vscode/settings.json and treat warnings as work to do, not noise:

{
  "al.enableCodeAnalysis": true,
  "al.codeAnalyzers": ["${CodeCop}", "${UICop}", "${PerTenantExtensionCop}"],
  "al.ruleSetPath": "./project.ruleset.json"
}

Use AppSourceCop instead of PerTenantExtensionCop if you publish to AppSource. It also checks for breaking changes against the previous version of your app.

5. Classify data and log telemetry

  • Set DataClassification on every field (for example CustomerContent or EndUserIdentifiableInformation) so privacy tooling and GDPR requests work correctly.
  • Add an Application Insights connection string to app.json and log important events with Session.LogMessage. When something fails in production, telemetry tells you what happened without a debugger.

6. Write automated tests

Test codeunits run inside Business Central and can drive real business logic. Use the given/when/then style and the standard test libraries (add the test toolkit apps as dependencies of your test app):

codeunit 50190 "DS Loyalty Tests"
{
    Subtype = Test;

    var
        Assert: Codeunit "Library Assert";
        LibrarySales: Codeunit "Library - Sales";

    [Test]
    procedure PostingFailsWithoutLoyaltyTier()
    var
        Customer: Record Customer;
        SalesHeader: Record "Sales Header";
    begin
        // [GIVEN] A customer on the default tier
        LibrarySales.CreateCustomer(Customer);

        // [WHEN] A sales order for that customer is posted
        LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No.");
        asserterror LibrarySales.PostSalesDocument(SalesHeader, true, true);

        // [THEN] Posting is blocked with a clear message
        Assert.ExpectedError('must have a loyalty tier');
    end;
}

7. Plan for install, upgrade and CI/CD

  • Use install codeunits (Subtype = Install) to set up default data, and upgrade codeunits with upgrade tags when your schema changes between versions.
  • Never remove or rename published fields without a deprecation path (ObsoleteState = Pending, then Removed).
  • Automate builds with AL-Go for GitHub or Azure DevOps pipelines. Compile, run the analyzers, run the tests and sign the app on every pull request.
  • Keep everything in Git, including the app.json version you bump on each release.

Quick checklist

AreaAsk yourself
DesignAm I using events and extensions, with no copies of standard objects?
NamingDo all objects and added fields have my affix and IDs inside my range?
PerformancePartial records, filtered loops, no commits in loops?
QualityAnalyzers clean, tests green, telemetry wired up?
LifecycleInstall and upgrade codeunits, obsoletion path, automated pipeline?

Wrapping up

Good AL is mostly about respecting the platform: extend through events, keep your footprint clearly named, read only the data you need, and let analyzers, tests and pipelines catch mistakes early. Your extension will then keep working through every Business Central release.

Looking for a Business Central developer to build or review your extensions? Get in touch.

Diwas Shrestha
Diwas Shrestha

.NET developer and Dynamics 365 Business Central technical consultant in Kathmandu, Nepal. I build AL extensions, upgrade NAV systems and integrate ERP with .NET applications. Work with me →

// keep reading