To Scope or not to Scope your AL procedures

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

https://youtu.be/TPZhMtoAACw

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 local or internal. 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.”