This guide shares what I wish I'd known from the start: practical AL insights from someone who learned through trial, error, and occasionally throwing things at my monitor.
AL isn't just "a programming language for Business Central." It's Microsoft's replacement for the old C/AL language, redesigned around modern extension-based architecture rather than direct code modifications.
In practical terms, AL is:
What makes AL unusual is its tight coupling with Business Central's application objects. You're not building standalone applications—you're creating extensions that hook into existing tables, pages, and codeunits through a publisher-subscriber model.
This fundamental difference tripped me up constantly when I started. I kept trying to build things from scratch instead of extending what was already there.
Skip the frustration of a broken setup with this tested approach:
You have two choices for where your code will actually run:
Option A: Docker Container (Local Development)
Option B: Business Central Sandbox (Cloud Development)
After trying both, I settled on a hybrid approach: Docker for daily coding with periodic deployment to a sandbox for integration testing.
$artifactUrl = Get-BcArtifactUrl -type OnPrem -version 22.0 -country us
$containerName = "BC220"
New-BcContainer `
-accept_eula `
-artifactUrl $artifactUrl `
-auth NavUserPassword `
-containerName $containerName `
-licenseFile "C:\path\to\your\license.flf" `
-updateHosts
Pro Tip: Save a working container as a Docker image for quick restarts without reinstallation.
Rather than a comprehensive language reference, here are the AL constructs I use in virtually every project:
Type | When to Use | Example |
---|---|---|
Text | For descriptions, names, etc. | Text[50] for fields that store short text |
Code | For IDs, numbers, or codes | Code[20] for item numbers, much faster than Text for keys |
Decimal | For money and quantities | Decimal for any numeric value that might have decimals |
Boolean | For yes/no flags | Boolean for simple toggles like "Approved" |
Enum | For status fields with fixed values | Perfect for status fields with defined options |
Data type tip: Use Code
instead of Text
for any field you'll search on frequently. Business Central optimizes Code
fields for lookups in ways it doesn't for Text
.
While AL supports standard programming constructs, these patterns appear most frequently:
Conditional Shorthand
if Customer."Credit Limit" < 1000 then
Customer."Credit Limit" := 1000;
For Each Loop (Used Constantly)
foreach Customer in Customers do begin
// Do something with each customer
Customer.CalcFields("Balance Due");
TotalBalance += Customer."Balance Due";
end;
Case Statement for Status Fields
case SalesHeader.Status of
SalesHeader.Status::Open:
CanEdit := true;
SalesHeader.Status::Released:
CanEdit := false;
SalesHeader.Status::"Pending Approval":
CanEdit := false;
end;
Early Returns for Validation
procedure IsValid(): Boolean
begin
if not HasRequired() then
exit(false);
if Amount <= 0 then
exit(false);
exit(true);
end;
Event subscribers let your code run when specific actions happen in Business Central. These are absolutely essential:
Table Events (Most Common)
[EventSubscriber(ObjectType::Table, Database::"Sales Header", 'OnAfterInsertEvent', '', false, false)]
local procedure OnAfterInsertSalesHeader(var Rec: Record "Sales Header")
begin
// Runs after a new sales header is created
Rec.Validate("Your Custom Field", 'Default Value');
Rec.Modify();
end;
Page Events (For UI Logic)
[EventSubscriber(ObjectType::Page, Page::"Customer Card", 'OnAfterGetCurrRecord', '', false, false)]
local procedure OnAfterGetCurrRecordCustomerCard(var Rec: Record Customer)
begin
// Runs when a user views a customer record
RefreshDashboard(Rec."No.");
end;
Codeunit Events (For Business Logic)
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnAfterPostSalesDoc', '', false, false)]
local procedure OnAfterPostSalesDoc(var SalesHeader: Record "Sales Header"; var GenJnlPostLine: Codeunit "Gen. Jnl.-Post Line"; ...)
begin
// Runs after a sales document is posted
CreateCustomFollow(SalesHeader);
end;
Pro tip: Always use local procedures for event subscribers with clear names describing what they do. This makes debugging much easier later.
Let's build something practical—a customer credit limit approval extension that adds a workflow for credit limit changes. This covers the core AL development patterns you'll use repeatedly:
First, extend the Customer table to add fields for the approval workflow:
tableextension 50100 "Credit Limit Ext" extends Customer
{
fields
{
field(50100; "Credit Limit Requested"; Decimal)
{
Caption = 'Credit Limit Requested';
DataClassification = CustomerContent;
}
field(50101; "Credit Limit Status"; Enum "Credit Limit Status")
{
Caption = 'Credit Limit Status';
DataClassification = CustomerContent;
}
field(50102; "Credit Limit Requested By"; Code[50])
{
Caption = 'Requested By';
DataClassification = CustomerContent;
TableRelation = "User Setup";
Editable = false;
}
}
}
Don't forget to create the enum for status:
enum 50100 "Credit Limit Status"
{
Extensible = true;
value(0; Open) { Caption = 'Open'; }
value(1; "Pending Approval") { Caption = 'Pending Approval'; }
value(2; Approved) { Caption = 'Approved'; }
value(3; Rejected) { Caption = 'Rejected'; }
}
Now add these fields to the Customer Card:
pageextension 50100 "Credit Limit Card Ext" extends "Customer Card"
{
layout
{
addafter("Credit Limit (LCY)")
{
field("Credit Limit Requested"; Rec."Credit Limit Requested")
{
ApplicationArea = All;
ToolTip = 'Specifies the requested credit limit pending approval.';
Editable = CanEditRequested;
}
field("Credit Limit Status"; Rec."Credit Limit Status")
{
ApplicationArea = All;
ToolTip = 'Specifies the status of the credit limit request.';
Editable = false;
}
}
}
actions
{
addfirst(processing)
{
action(RequestApproval)
{
ApplicationArea = All;
Caption = 'Request Credit Approval';
Image = SendApprovalRequest;
ToolTip = 'Send the credit limit change for approval.';
trigger OnAction()
begin
RequestCreditApproval();
end;
}
}
}
var
CanEditRequested: Boolean;
trigger OnAfterGetRecord()
begin
SetEditableControls();
end;
local procedure SetEditableControls()
begin
CanEditRequested := Rec."Credit Limit Status" = Rec."Credit Limit Status"::Open;
end;
local procedure RequestCreditApproval()
begin
if Rec."Credit Limit Requested" <= 0 then begin
Error('Please specify a requested credit limit amount.');
exit;
end;
Rec.Validate("Credit Limit Status", Rec."Credit Limit Status"::"Pending Approval");
Rec.Validate("Credit Limit Requested By", UserId);
Rec.Modify();
Message('Approval request sent.');
end;
}
Next, create a codeunit to handle the approvals:
codeunit 50100 "Credit Limit Approval Mgmt"
{
procedure ApproveRequest(CustomerNo: Code[20])
var
Customer: Record Customer;
begin
if Customer.Get(CustomerNo) then begin
Customer.Validate("Credit Limit (LCY)", Customer."Credit Limit Requested");
Customer.Validate("Credit Limit Status", Customer."Credit Limit Status"::Approved);
Customer.Modify();
end;
end;
procedure RejectRequest(CustomerNo: Code[20])
var
Customer: Record Customer;
begin
if Customer.Get(CustomerNo) then begin
Customer.Validate("Credit Limit Status", Customer."Credit Limit Status"::Rejected);
Customer.Modify();
end;
end;
}
Finally, create a page for the approvals:
page 50100 "Credit Limit Approvals"
{
PageType = List;
SourceTable = Customer;
Caption = 'Credit Limit Approval Requests';
CardPageId = "Customer Card";
SourceTableView = where("Credit Limit Status" = const("Pending Approval"));
layout
{
area(Content)
{
repeater(Requests)
{
field("No."; Rec."No.") { ApplicationArea = All; }
field(Name; Rec.Name) { ApplicationArea = All; }
field("Credit Limit (LCY)"; Rec."Credit Limit (LCY)") { ApplicationArea = All; }
field("Credit Limit Requested"; Rec."Credit Limit Requested") { ApplicationArea = All; }
field("Credit Limit Requested By"; Rec."Credit Limit Requested By") { ApplicationArea = All; }
}
}
}
actions
{
area(Processing)
{
action(Approve)
{
ApplicationArea = All;
Caption = 'Approve';
Image = Approve;
trigger OnAction()
var
CreditApproval: Codeunit "Credit Limit Approval Mgmt";
begin
CreditApproval.ApproveRequest(Rec."No.");
CurrPage.Update(false);
end;
}
action(Reject)
{
ApplicationArea = All;
Caption = 'Reject';
Image = Reject;
trigger OnAction()
var
CreditApproval: Codeunit "Credit Limit Approval Mgmt";
begin
CreditApproval.RejectRequest(Rec."No.");
CurrPage.Update(false);
end;
}
}
}
}
With this simple extension, you've added:
The difference between an amateur and professional AL developer often comes down to debugging skill. Here's what works:
When things aren't working, add strategic message calls:
procedure ProcessSomething()
var
Customer: Record Customer;
begin
Message('Starting process for %1 customers', Customer.Count());
if Customer.FindSet() then
repeat
Message('Processing customer %1', Customer."No.");
// Your code here
until Customer.Next() = 0;
Message('Process complete');
end;
Business Central's Event Recorder captures precisely which events fire as you use the system:
This is invaluable for finding the right event to subscribe to.
VS Code's AL Object Browser shows you exactly what fields, methods, and events exist:
Pro tip: The field tooltips show the exact data type and properties, saving countless trips to Microsoft docs.
These patterns come from real extensions I've built and deployed:
When updating fields, wrap in a validate-modify pattern:
procedure UpdateCustomerEmail(CustomerNo: Code[20]; NewEmail: Text[80])
var
Customer: Record Customer;
begin
if not Customer.Get(CustomerNo) then
exit;
if Customer."E-Mail" <> NewEmail then begin
Customer.Validate("E-Mail", NewEmail);
Customer.Modify(true);
end;
end;
When creating records that might already exist:
procedure EnsureRecordExists(ItemNo: Code[20]; LocationCode: Code[10])
var
ItemLocation: Record "Item Location";
begin
ItemLocation.SetRange("Item No.", ItemNo);
ItemLocation.SetRange("Location Code", LocationCode);
if not ItemLocation.FindFirst() then begin
// Insert new
ItemLocation.Init();
ItemLocation."Item No." := ItemNo;
ItemLocation."Location Code" := LocationCode;
ItemLocation.Insert(true);
end else begin
// Update existing if needed
if ItemLocation.Status <> ItemLocation.Status::Active then begin
ItemLocation.Status := ItemLocation.Status::Active;
ItemLocation.Modify(true);
end;
end;
end;
For complex calculations without database impact:
procedure CalculateDiscounts()
var
SalesLine: Record "Sales Line";
TempDiscount: Record "Sales Line" temporary;
Item: Record Item;
begin
if SalesLine.FindSet() then
repeat
// Skip to temp table for processing
TempDiscount.Init();
TempDiscount.TransferFields(SalesLine);
// Do calculations
if Item.Get(SalesLine."No.") then
TempDiscount."Line Discount %" := CalculateItemDiscount(Item);
TempDiscount.Insert();
until SalesLine.Next() = 0;
// Now process the temp table
if TempDiscount.FindSet() then
repeat
// Update actual records
SalesLine.Get(TempDiscount."Document Type", TempDiscount."Document No.", TempDiscount."Line No.");
SalesLine."Line Discount %" := TempDiscount."Line Discount %";
SalesLine.Modify();
until TempDiscount.Next() = 0;
end;
Learn from my pain:
What I did wrong: Tried to directly modify the Sales Header table in code without using events or proper API.
Why it's a problem: This breaks when Microsoft updates the base application.
Better approach: Use event subscribers to react to changes rather than making direct modifications.
What I did wrong: Created a list extension that ran expensive calculations on each record.
What happened: The page took 30+ seconds to load with just 100 records.
Better approach: Calculate values in batch processes and store results, or use FlowFields for calculated values.
What I did wrong: Hardcoded approval thresholds and business rules directly in AL code.
What happened: Every rule change required a code change and redeployment.
Better approach: Create setup tables for configurable business rules that users can modify.
Getting your code into production safely:
For any serious development:
Follow semantic versioning for your app:
1.0.0.0
│ │ │ │
│ │ │ └─ Build number (increment for minor fixes)
│ │ └─── Minor version (increment for new features)
│ └───── Major version (increment for breaking changes)
└─────── Major version (rarely changes)
Update the version in app.json with each release.
Once you've mastered the basics, these advanced techniques separate good developers from great ones:
After five years of building AL extensions, I've learned that mastery doesn't come from memorizing syntax or studying documentation—it comes from building real solutions that solve actual business problems.
Start small. Build extensions that address specific needs in your organization. Learn from what works and what doesn't. Gradually tackle more complex challenges as your confidence grows.
The most valuable skill isn't knowing every AL function by heart—it's knowing how to translate business requirements into working code that integrates seamlessly with Business Central's existing functionality.
Learn how to streamline your sales order and invoicing processes with best practices, digital tools, and real-world tips. Ideal for SMB owners and financial managers.
Kery Nguyen
2024-07-20
Learn how to use audit trails in Microsoft Dynamics 365 Business Central to track changes, maintain data integrity, and ensure accountability across your business operations.
Kery Nguyen
2024-07-01
Learn to navigate and use Microsoft Dynamics 365 Business Central with greater efficiency using our comprehensive guide to keyboard shortcuts. Perfect for financial professionals, managers, and project leaders aiming to boost productivity.
Kery Nguyen
2024-06-29