Email this post as a PDFDevelopment · 6 November 2026 · 15 min read

AL development from scratch, part 4: table extensions, enums and enum extensions

How to add your own fields to Microsoft's tables with a table extension, what you can and cannot change, and how to build enums and extend them from another app, with the full Pallet Manager code.

Contents
  1. Why extensions, not modifications
  2. What a table extension can and cannot do
  3. Naming and numbering rules
  4. The BBX Item table extension
  5. Changing existing fields with modify
  6. Table triggers in a table extension
  7. Where the data actually goes
  8. Enums: the modern Option field
  9. Enum extensions: adding values from another app
  10. Common mistakes
  11. Where we are

This is part 4 of AL development from scratch. In part 2 and part 3 we built our own table, BBX Pallet Type, from nothing. Most real extensions, though, spend more time changing Microsoft's tables than creating new ones. A customer wants a field on the item, a flag on the customer, a reference on the sales header. That is what a table extension is for.

In this part we add two fields to the standard Item table, so every item can carry a default pallet type and the number of units that fit on one pallet. Then we look properly at enums, the modern replacement for Option fields, and at enum extensions, which let one app add values to an enum that belongs to another.

Why extensions, not modifications

If you come from NAV, you will remember opening table 27 in C/SIDE, adding fields at the bottom and saving. That changed Microsoft's object directly, which is why every upgrade meant merging your changes into the new version by hand.

In Business Central you cannot change Microsoft's objects at all. You describe your changes in a separate object that extends theirs. Microsoft can then ship a new version of the Item table every month, and your table extension still applies on top. That is the whole reason extensions exist, and it explains most of the rules below: you can add, and you can adjust a few things, but you cannot take away or redefine what Microsoft owns.

What a table extension can and cannot do

You canYou cannot
Add new fieldsDelete or rename a field that belongs to the base table
Add secondary keysChange the primary key
Change a small set of properties on existing fields (captions, tooltips and similar)Change a base field's data type, length or field class
Add code to run before and after the base table's triggersReplace or remove the base table's own trigger code
Add code to run before and after validation of existing fieldsChange the base field's own OnValidate code
Add procedures and global variablesCall procedures in the base table that are marked local or internal

If you need to change behaviour that the base table controls, the answer is usually an event subscriber, not a table extension. Microsoft publishes events at the points where they expect partners to need a say. We cover events in part 7.

Naming and numbering rules

  • The object ID of the table extension comes from your app's ID range (ours is 50100 to 50149). It is a separate numbering from tables, so table extension 50100 and table 50100 can both exist.
  • Field IDs in a table extension must also come from your app's ID range. This is how Business Central stops two apps adding field 50100 to the Item table and colliding.
  • Field names must be unique across the base table and every extension of it. If Microsoft adds a field called "Units per Pallet" next year and you already have one with the same name, you have a problem. That is why you put your prefix on every field you add: "BBX Units per Pallet" can never clash.
  • Object names follow the same 30-character limit as every other object. Name the extension after what it extends, with your prefix: "BBX Item", "BBX Customer", "BBX Sales Header".

One file per object, named consistently, keeps the project easy to navigate. We use BBXItem.TableExt.al. There is more on conventions in naming objects in Business Central development.

The BBX Item table extension

Here is the complete object. Read it through once, then we will take it apart.

namespace BusinessBoxed.PalletManager;

using Microsoft.Inventory.Item;

tableextension 50100 "BBX Item" extends Item
{
    fields
    {
        field(50100; "BBX Default Pallet Type"; Code[20])
        {
            Caption = 'Default Pallet Type';
            ToolTip = 'Specifies the pallet type this item is normally shipped and stored on.';
            DataClassification = CustomerContent;
            TableRelation = "BBX Pallet Type" where(Blocked = const(false));

            trigger OnValidate()
            begin
                if Rec."BBX Default Pallet Type" = '' then
                    Rec."BBX Units per Pallet" := 0;
                CheckPalletWeight();
            end;
        }
        field(50101; "BBX Units per Pallet"; Integer)
        {
            Caption = 'Units per Pallet';
            ToolTip = 'Specifies how many units of this item fit on one pallet of the default pallet type.';
            DataClassification = CustomerContent;
            MinValue = 0;
            BlankZero = true;

            trigger OnValidate()
            begin
                if Rec."BBX Units per Pallet" <> 0 then
                    Rec.TestField("BBX Default Pallet Type");
                CheckPalletWeight();
            end;
        }
    }

    keys
    {
        key(BBXPalletType; "BBX Default Pallet Type")
        {
        }
    }

    fieldgroups
    {
    }

    var
        TooHeavyErr: Label 'A full pallet of %1 weighs %2 kg, which is more than the %3 kg allowed for pallet type %4.', Comment = '%1 = Item No., %2 = total weight, %3 = maximum weight, %4 = pallet type code';

    procedure CheckPalletWeight()
    var
        PalletType: Record "BBX Pallet Type";
        TotalWeight: Decimal;
    begin
        if (Rec."BBX Default Pallet Type" = '') or (Rec."BBX Units per Pallet" = 0) then
            exit;
        if not PalletType.Get(Rec."BBX Default Pallet Type") then
            exit;
        if PalletType."Max. Weight (kg)" = 0 then
            exit;
        TotalWeight := Rec."Net Weight" * Rec."BBX Units per Pallet";
        if TotalWeight > PalletType."Max. Weight (kg)" then
            Error(TooHeavyErr, Rec."No.", TotalWeight, PalletType."Max. Weight (kg)", PalletType.Code);
    end;
}

The empty fieldgroups section is there only to show you where it would go. You can leave it out entirely.

The namespace and using lines

The file starts with namespace BusinessBoxed.PalletManager;, the same namespace as every other object in Pallet Manager, followed by using Microsoft.Inventory.Item;. Microsoft's objects live in namespaces too, and the Item table is in Microsoft.Inventory.Item. The using line tells the compiler where to find it, the same way an import works in other languages. Our own objects, such as the "BBX Pallet Type" table in the TableRelation, need no using because they are in the same namespace.

Namespaces are optional. Leave both lines out and the extension goes into the global namespace and works exactly as before. We show them throughout this series because they are the modern standard and Microsoft's own code uses them; whether your team adopts them is your call. Either way, keep the BBX prefix: object names still have to be unique. There is a fuller explanation in part 1. When you extend any other Microsoft table and are not sure of its namespace, press F12 on its name and look at the namespace line at the top of the definition.

The header

tableextension 50100 "BBX Item" extends Item says: this is table extension number 50100, called "BBX Item", and it extends the table called Item. You refer to the base table by name, not by number. Press F12 on Item in Visual Studio Code and you jump straight into the symbol for Microsoft's table, where you can read every field, key and property it has. That is the single most useful habit you can build as a new AL developer: when in doubt, go to the definition.

The new fields

Fields in a table extension are written exactly as in a normal table (see part 2): ID, name, data type, then properties. Everything you learned there applies here, including DataClassification, which every new field must have (more on why in data classification and GDPR).

A few things worth noticing:

  • The TableRelation has a filter. where(Blocked = const(false)) means the lookup only shows pallet types that are not blocked, and validation rejects a blocked one. We covered table relations in part 3.
  • MinValue = 0 stops anyone typing a negative number of units. Let the platform enforce simple rules like this rather than writing code for them.
  • BlankZero = true shows an empty cell instead of 0, which makes lists easier to read when most items have no value.
  • The ToolTip is on the table field. Every page that shows the field inherits it, so you write it once.

The OnValidate triggers

Field triggers on your own new fields are written in full, as in any table. Here, clearing the pallet type also clears the units, and both fields check the total weight. The check itself lives in a procedure, CheckPalletWeight, so it is written once and called from both places.

The error message is a Label with a Comment explaining each placeholder. That is not decoration. Labels are what get translated, and the comment is what tells a translator that %2 is a weight and not a quantity. Never pass a hard-coded string to Error, Message or Confirm.

Notice the explicit Rec. in front of every field. Older code relied on an implicit "with" and wrote just "Net Weight". Modern AL expects you to say which record you mean, and it makes the code far easier to read once a procedure has two or three record variables in play.

The key

We add a secondary key on our new field, because we will often ask "which items use this pallet type?". The FlowField "No. of Items" on the pallet type table does exactly that count, and the report in part 8 filters on it. A key makes those filters fast on a large item table. Keys follow the same advice as in part 3: add them for filters and sorts you actually use, not speculatively, because every key slows down every insert and modify.

Changing existing fields with modify

You cannot change a base field's type or remove it, but you can adjust a few of its properties and hook into its validation. You do that with modify inside the fields section:

fields
{
    modify("Net Weight")
    {
        trigger OnAfterValidate()
        begin
            Rec.CheckPalletWeight();
        end;
    }
}

Now, when a user changes the item's net weight, our weight check runs after Microsoft's own validation. Put this inside the same fields section as the new fields in "BBX Item", and the rule is complete: whichever of the three values changes, the pallet is checked.

Two triggers are available on a modified field:

TriggerWhen it runsTypical use
OnBeforeValidateBefore the base field's own OnValidate codeChecking something before Microsoft's logic acts on the new value
OnAfterValidateAfter the base field's own OnValidate codeReacting to the change: recalculating, checking, setting your own fields

Prefer OnAfterValidate unless you have a specific reason. Code that runs before Microsoft's logic is more likely to be surprised when that logic changes in a future release.

Properties you can safely change on an existing field include:

PropertyWhat it doesExample / default
CaptionChanges the field's caption everywhere it is shownCaption = 'Shelf Code';
CaptionClassMakes the caption dynamic, resolved at runtimeRarely needed in a first extension
ToolTipReplaces the tooltip that pages inherit from the fieldToolTip = 'Specifies ...';
OptionCaptionChanges the captions of an Option field's valuesOnly for Option fields
DescriptionAn internal note for developers; not shown to usersFree text

The compiler is the final word: if you try to modify a property that is not allowed in a table extension, Visual Studio Code underlines it straight away. Change captions sparingly. A user who reads Microsoft's documentation, or calls Microsoft support, expects the standard name.

Table triggers in a table extension

A normal table has OnInsert, OnModify, OnDelete and OnRename. A table extension cannot replace those, but it can add code either side of them:

TriggerWhen it runs
OnBeforeInsert / OnAfterInsertEither side of the base table's OnInsert, when a record is inserted with trigger code running
OnBeforeModify / OnAfterModifyEither side of OnModify
OnBeforeDelete / OnAfterDeleteEither side of OnDelete
OnBeforeRename / OnAfterRenameEither side of OnRename

For example, to stop an item being deleted while it still has a pallet type set (purely to show the shape; you may not want this rule):

trigger OnBeforeDelete()
var
    HasPalletErr: Label 'Item %1 still has pallet type %2. Clear the pallet type before deleting the item.', Comment = '%1 = Item No., %2 = pallet type code';
begin
    if Rec."BBX Default Pallet Type" <> '' then
        Error(HasPalletErr, Rec."No.", Rec."BBX Default Pallet Type");
end;

Table triggers only run when the code that inserts, modifies or deletes the record asks for them, for example Item.Insert(true) rather than Item.Insert(). Users working on pages always run them. Background processes and imports may not. If a rule must hold however the record is written, do not rely on table triggers alone. Validation at the point of entry, plus a check just before the thing that matters (posting, shipping), is usually the robust design.

Where the data actually goes

You do not need to know how Business Central stores your extra fields to write a table extension, but it helps when someone asks about performance or reporting. The short version: your fields are not physically added to Microsoft's table in the same way they were in NAV. We explain the details, and what changes in version 29, in where your bespoke fields actually live.

One consequence matters straight away: once your extension is installed and holds data, you cannot casually change a field's type or delete it. Business Central protects the data. Changing a live extension is a topic in its own right, covered in changing a live extension without losing data and in part 10.

Enums: the modern Option field

In NAV, a field that could hold one of a fixed list of values was an Option: OptionMembers = " ",Wood,Plastic,Metal. Options still exist in AL, but they have a serious weakness: nobody else can add a value. If a customer needs "Composite" pallets, the only fix is to change the original field.

An enum is a list of values defined as its own object, which tables, pages, codeunits and other apps can all use. If you mark it as extensible, other apps can add values to it without touching your code.

Here is the enum that our pallet type's Material field uses:

namespace BusinessBoxed.PalletManager;

enum 50100 "BBX Pallet Material"
{
    Extensible = true;
    Caption = 'Pallet Material';

    value(0; " ")
    {
        Caption = ' ', Locked = true;
    }
    value(1; Wood)
    {
        Caption = 'Wood';
    }
    value(2; Plastic)
    {
        Caption = 'Plastic';
    }
    value(3; Metal)
    {
        Caption = 'Metal';
    }
    value(4; Cardboard)
    {
        Caption = 'Cardboard';
    }
}

Each value has three parts: an ordinal (the number that is stored in the database), a name (what you write in code) and a caption (what the user sees, and what gets translated). The blank value 0 is a convention for "not set yet"; its caption is a single space and is locked, because there is nothing to translate.

The field in the table that uses it is simply:

field(4; Material; Enum "BBX Pallet Material")
{
    Caption = 'Material';
    ToolTip = 'Specifies what the pallet is made of.';
    DataClassification = CustomerContent;
}

Enum properties

PropertyWhat it doesExample / default
ExtensibleWhether other apps may add values with an enum extensionDefaults to false. Set it to true unless you have a reason not to
CaptionThe enum's own captionCaption = 'Pallet Material';
AccessWhether other apps can use the enum at all (Public or Internal)Public by default
ImplementsLists interfaces that every value of the enum must provide an implementation forSee below
DefaultImplementationThe implementation used by values that do not name their ownUsed with Implements
UnknownValueImplementationThe implementation used if a stored value no longer exists, for example because the app that added it was uninstalledUsed with Implements
ObsoleteState, ObsoleteReason, ObsoleteTagMarks the enum as being phased outSame pattern as for fields and tables

On each value you set Caption, and, if the enum implements interfaces, Implementation to say which codeunit implements them for that value. Values can be marked obsolete too.

Why enums beat Option fields

  • They can be extended by other apps, without changing yours.
  • They are defined once and reused. A "BBX Pallet Material" field on the pallet type, on a journal line and on a report request page all share one list, so they can never drift apart.
  • Captions are proper translatable captions, one per value, rather than a comma-separated OptionCaption string where one misplaced comma shifts every translation.
  • They can implement interfaces, which turns a list of values into a list of behaviours. That is how Business Central lets an app add, for example, a new way to calculate something without rewriting the code that calls it.

Use Option only for small, private, fixed lists inside a codeunit, where nobody will ever need to extend them. Everywhere else, use an enum.

Using enums in code

You refer to a value by enum name and value name:

var
    PalletType: Record "BBX Pallet Type";
    Material: Enum "BBX Pallet Material";
    MaterialList: Text;
    Ordinal: Integer;
    NotPlasticMsg: Label 'Pallet type %1 is %2, not plastic.', Comment = '%1 = pallet type code, %2 = material caption';
begin
    // Assigning a value
    Material := Enum::"BBX Pallet Material"::Wood;
    PalletType.Material := PalletType.Material::Plastic;

    // Comparing
    if PalletType.Material <> PalletType.Material::Plastic then
        Message(NotPlasticMsg, PalletType.Code, Format(PalletType.Material));

    // The stored number, and back again
    Ordinal := Material.AsInteger();
    Material := Enum::"BBX Pallet Material".FromInteger(Ordinal);

    // Every value the enum has, including values added by other apps
    foreach Ordinal in Enum::"BBX Pallet Material".Ordinals() do
        MaterialList += Format(Enum::"BBX Pallet Material".FromInteger(Ordinal)) + ' ';
end;

A few points:

  • Format(value) gives you the caption in the user's language. Use it for anything a user will read.
  • Names() gives the list of value names, and Ordinals() the list of ordinals, in the same order. Both include values added by enum extensions. Use them when you need to loop over every possible value.
  • AsInteger() and FromInteger() convert between an enum value and its ordinal. You need them less often than you might think; compare values directly wherever you can.

Case statements on an extensible enum

This catches out almost everyone once. Because the enum is extensible, another app may add a value you have never heard of. A case statement that lists only your values will silently do nothing for theirs.

case PalletType.Material of
    PalletType.Material::Wood:
        ReturnFee := 5;
    PalletType.Material::Plastic,
    PalletType.Material::Metal:
        ReturnFee := 12;
    PalletType.Material::Cardboard:
        ReturnFee := 0;
    else
        Error(UnhandledMaterialErr, Format(PalletType.Material));
end;

Always decide what the else branch should do: raise a clear error, apply a sensible default, or (better still, for anything more than a few lines) move the behaviour into an interface so that whoever adds the value also supplies the logic. That last approach is explained with a worked example in interfaces and enums in AL. The UnhandledMaterialErr label would be declared in the procedure's var section like any other.

Enum extensions: adding values from another app

Now imagine a customer wants composite pallets, and you decide that belongs in a separate add-on app, "Pallet Manager Plus", rather than in Pallet Manager itself. Because "BBX Pallet Material" is extensible, Pallet Manager Plus can add the value without anyone touching the original app.

Step 1: the dependency in app.json

Pallet Manager Plus has to know about Pallet Manager before it can extend its enum. You declare that in the second app's app.json, using the values from the first app's own app.json:

"dependencies": [
    {
        "id": "3f1c2a9e-6b7d-4e21-9c55-0a8b7e4d2f10",
        "name": "Pallet Manager",
        "publisher": "Business Boxed Limited",
        "version": "1.0.0.0"
    }
]

The id must match the id in Pallet Manager's app.json exactly; the GUID above is just the one our example app happens to use. The version is the minimum version required. After adding the dependency, run AL: Download Symbols so Visual Studio Code can see Pallet Manager's objects, and make sure Pallet Manager is installed in the environment you publish to. Part 1 has more on app.json.

Step 2: the enum extension

Pallet Manager Plus has its own prefix (BBXP) and its own ID range (50150 to 50199):

namespace BusinessBoxed.PalletManagerPlus;

using BusinessBoxed.PalletManager;

enumextension 50150 "BBXP Pallet Material" extends "BBX Pallet Material"
{
    value(50150; "BBXP Composite")
    {
        Caption = 'Composite';
    }
}

Notice the namespace. Pallet Manager Plus is a different app, so it has its own namespace, BusinessBoxed.PalletManagerPlus. The enum it extends lives in BusinessBoxed.PalletManager, so the file needs using BusinessBoxed.PalletManager; to see it. Without that line (and with namespaces in use), the compiler reports that "BBX Pallet Material" cannot be found, even though the dependency is in app.json. The dependency makes the other app's objects available; the using makes their names visible in this file. If neither app used namespaces, everything would sit in the global namespace and no using would be needed.

The rules mirror those for table extension fields:

  • The ordinal must be in the extending app's ID range. That is why the value is 50150, not 5. If two apps both added value 5, the stored numbers would collide.
  • The value name must be unique across the enum and all its extensions, so prefix it. The caption, which users see, does not need the prefix.
  • You cannot change or remove existing values, only add new ones.

Once both apps are installed, "Composite" appears in the Material dropdown on the pallet type card, Ordinals() and Names() include it, and any case statement in Pallet Manager that lacks an else branch quietly ignores it. You can see why the previous section matters.

Extending Microsoft's enums

Many of Microsoft's enums are extensible, and extending them is a common way to plug into standard features. Not all of them are, though. Before you plan a design around adding a value, press F12 on the enum in Visual Studio Code and check for Extensible = true in its definition. While you are there, note the namespace line at the top: your enum extension file needs a matching using. If it is not there, an enum extension will not compile, and you will need a different approach.

Common mistakes

  • Forgetting the prefix on extension fields. "Units per Pallet" works today and clashes the day Microsoft or another app adds a field of the same name.
  • Using a field ID outside your range. The compiler warns you if your range is set correctly in app.json. Take the warning seriously.
  • Putting business rules in OnBeforeValidate of a base field. Use OnAfterValidate, or an event, unless you genuinely need to act before Microsoft's code.
  • Relying on table triggers for rules that must always hold. Code that writes records without running triggers will skip them.
  • Using an Option field for a list that might grow. Converting a populated Option field to an enum later is far more work than starting with an enum.
  • Missing a using directive. With namespaces in use, "Table Item is missing" usually means the file has no using Microsoft.Inventory.Item;, not that a dependency is wrong. F12 on the object shows the namespace to add.
  • Leaving Extensible at its default. If you do not set Extensible = true, nobody, including you in a later add-on, can add values.
  • A case statement with no else branch on an extensible enum.
  • Using small ordinals in an enum extension. They must come from your app's ID range.
  • Hard-coded text in Error or Message. Always a Label, with a Comment for each placeholder.

Where we are

Pallet Manager now has its own table, a proper enum, and two new fields on every item, with validation that checks the pallet weight whichever value changes. What it does not have yet is any way for a user to see it. In part 5 we build the pages.

Previous: part 3: keys, FlowFields, table relations and triggers

Next: part 5: building a page

Back to the series contents

Want help with your first extension? Book a 30-minute call.

Email this post as a PDF
A branded PDF of this post, sent to your inbox to keep, print or share. Copy in colleagues if you like.
We only use these addresses to send this PDF. Nobody is added to a mailing list. Mike at Business Boxed receives a copy. See our privacy notice.
Email this post
Talk to us
Want this done in your Business Central?
Book a 30 minute callGet the monthly newsletter

Related posts