In this video, I talk about the importance of scoping AL procedures properly.

In this video, Erik tackles a topic that’s been on his list for a while: procedure scoping in AL for Business Central. The catalyst? A real-world bug at a customer site that could have been entirely prevented with proper scoping. Erik walks through the three levels of procedure scoping—public, local, and internal—and explains when and why to use each one.
The Bug That Started It All
Erik keeps a running list of video topics based on community suggestions. Procedure scoping had been on that list for a while, but he needed the right angle to make it an interesting video. That angle arrived when a strange error appeared at a customer site.
The root cause? Someone called a procedure from outside a codeunit that was never designed to be called externally. With proper procedure scoping, the procedure wouldn’t have been accessible in the first place, and the bug would never have existed.
Default Scope: Public
When you create a procedure in AL without any access modifier, it defaults to public. This means anyone—within the same codeunit, within the same extension, or even from another extension with a dependency—can call it.
procedure Proc1()
begin
end;
This is a historical artifact. In the old NAV days, the only scope available was global. So when you type procedure without thinking, you’re making it available to the entire world. This is the opposite of languages like C#, where everything is local (private) by default and you must explicitly write public to widen the scope.
The Real-World Bug: Why Scoping Matters
To illustrate the problem, Erik sets up a codeunit with a global temporary record variable, a Process procedure, and a helper SubProcess procedure:
codeunit 50100 "Procedures"
{
var
Tmp: Record Customer temporary;
procedure Process()
var
C: Record Customer;
begin
Tmp.DeleteAll();
if C.FindSet() then
repeat
SubProcess();
until C.Next() = 0;
end;
procedure SubProcess()
begin
Tmp.Insert();
end;
}
The intended usage is that you call Process(), which first clears the temporary table with DeleteAll(), then loops through customers and calls SubProcess() to populate the temporary table.
The bug occurred when someone from outside the codeunit decided to call SubProcess() directly, bypassing Process() entirely. This worked fine in a single call, but when placed inside a loop, the Tmp.Insert() started failing because DeleteAll() was never called—duplicate records caused insert errors.
The fix is simple: SubProcess was never designed to be called from outside the codeunit, so it should be marked as local.
The Three Scoping Levels
Local
A local procedure can only be called from within the same object (codeunit, page, table, etc.). This is the most restrictive scope and should be your go-to for helper procedures that serve as internal implementation details.
local procedure SubProcess()
begin
Tmp.Insert();
end;
Once marked local, any attempt to call SubProcess() from another codeunit results in a compile-time error: “inaccessible due to its protection level.”
Internal
An internal procedure is accessible from anywhere within the same app/extension, but not from other extensions that take a dependency on yours. This is perfect for procedures that need to be shared across multiple objects within your extension but shouldn’t be part of your public API.
internal procedure Proc1()
begin
end;
internal procedure Process()
var
C: Record Customer;
begin
Tmp.DeleteAll();
if C.FindSet() then
repeat
SubProcess();
until C.Next() = 0;
end;
Public (Default)
A public procedure (or simply a procedure with no keyword) is available to everyone—within the object, within the extension, and from other extensions. This should be reserved for procedures that are genuinely part of your public API.
procedure Test()
begin
Proc1();
end;
The Complete Example
Here’s the final version of the codeunit with proper scoping applied:
codeunit 50100 "Procedures"
{
var
Tmp: Record Customer temporary;
internal procedure Proc1()
begin
end;
internal procedure Process()
var
C: Record Customer;
begin
Tmp.DeleteAll();
if C.FindSet() then
repeat
SubProcess();
until C.Next() = 0;
end;
local procedure SubProcess()
begin
Tmp.Insert();
end;
procedure Test()
begin
Proc1();
end;
}
And the second codeunit that consumes it:
codeunit 50101 "More"
{
procedure More()
var
Proc: Codeunit Procedures;
begin
Proc.Proc1();
end;
}
Note that with Proc1() marked as internal, the call in More.al works because both codeunits are in the same extension. If More were in a different extension, this call would fail at compile time.
A Note on “Protected”
Erik also mentions that you can mark a procedure as protected, which currently behaves the same as local. However, this is largely undocumented and its intended purpose in the context of AL procedures is unclear. Erik calls on Microsoft to clarify or update their documentation on this keyword.
Practical Guidance
- Think about scope for every procedure you write. Don’t just default to public out of habit.
- If a procedure is a helper/implementation detail, mark it
local. - If a procedure needs to be shared across your extension but shouldn’t be part of your public API, mark it
internal. - Only leave procedures public if they are genuinely meant to be called by other extensions.
- If your codeunit has dozens of functions but only three should be called externally, make those three public and everything else
localorinternal. Consumers of your app will see a clean, focused API instead of a wall of functions.
Summary
Proper procedure scoping in AL is a simple practice that pays dividends. It prevents bugs (like the real-world one Erik encountered), keeps your API clean for consumers, and makes your code’s intent explicit. AL defaults to public for historical reasons, but that doesn’t mean you should accept the default. Scope your procedures deliberately: use local for internal helpers, internal for app-wide shared procedures, and leave only your true public interface without a scope keyword. As Erik puts it: “To scope or not to scope? I would say: to scope.”