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.
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.
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.
| You can | You cannot |
|---|---|
| Add new fields | Delete or rename a field that belongs to the base table |
| Add secondary keys | Change 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 triggers | Replace or remove the base table's own trigger code |
| Add code to run before and after validation of existing fields | Change the base field's own OnValidate code |
| Add procedures and global variables | Call 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.
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.
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 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.
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.
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:
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.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.
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.
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:
| Trigger | When it runs | Typical use |
|---|---|---|
| OnBeforeValidate | Before the base field's own OnValidate code | Checking something before Microsoft's logic acts on the new value |
| OnAfterValidate | After the base field's own OnValidate code | Reacting 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:
| Property | What it does | Example / default |
|---|---|---|
| Caption | Changes the field's caption everywhere it is shown | Caption = 'Shelf Code'; |
| CaptionClass | Makes the caption dynamic, resolved at runtime | Rarely needed in a first extension |
| ToolTip | Replaces the tooltip that pages inherit from the field | ToolTip = 'Specifies ...'; |
| OptionCaption | Changes the captions of an Option field's values | Only for Option fields |
| Description | An internal note for developers; not shown to users | Free 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.
A normal table has OnInsert, OnModify, OnDelete and OnRename. A table extension cannot replace those, but it can add code either side of them:
| Trigger | When it runs |
|---|---|
| OnBeforeInsert / OnAfterInsert | Either side of the base table's OnInsert, when a record is inserted with trigger code running |
| OnBeforeModify / OnAfterModify | Either side of OnModify |
| OnBeforeDelete / OnAfterDelete | Either side of OnDelete |
| OnBeforeRename / OnAfterRename | Either 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.
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.
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;
}
| Property | What it does | Example / default |
|---|---|---|
| Extensible | Whether other apps may add values with an enum extension | Defaults to false. Set it to true unless you have a reason not to |
| Caption | The enum's own caption | Caption = 'Pallet Material'; |
| Access | Whether other apps can use the enum at all (Public or Internal) | Public by default |
| Implements | Lists interfaces that every value of the enum must provide an implementation for | See below |
| DefaultImplementation | The implementation used by values that do not name their own | Used with Implements |
| UnknownValueImplementation | The implementation used if a stored value no longer exists, for example because the app that added it was uninstalled | Used with Implements |
| ObsoleteState, ObsoleteReason, ObsoleteTag | Marks the enum as being phased out | Same 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.
Use Option only for small, private, fixed lists inside a codeunit, where nobody will ever need to extend them. Everywhere else, use an enum.
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:
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.
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.
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.
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:
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.
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.
using Microsoft.Inventory.Item;, not that a dependency is wrong. F12 on the object shows the namespace to add.Extensible = true, nobody, including you in a later add-on, can add values.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
Want help with your first extension? Book a 30-minute call.