Email this post as a PDFNewDevelopment · 6 November 2026 · 16 min read

AL development from scratch, part 10: permissions, install and upgrade codeunits, tests and shipping

The last mile: permission sets so people can use the app, install and upgrade codeunits so data is right on day one and every version after, automated tests, and how to publish a per-tenant extension to production safely.

Contents
  1. Permission sets
  2. Install codeunits: arriving with data
  3. Upgrade codeunits: every version after
  4. Tests
  5. Shipping
  6. Common mistakes
  7. That's the series

This is the final part of AL development from scratch. Pallet Manager works: it has its table, pages, Item fields, logic, a report, a query, an XMLport and an API. On your sandbox, signed in as yourself with SUPER, everything is fine.

That is the trap. The moment a normal user opens the Pallet Types list in production, they get a permissions error. The first time you ship version 1.1 with a new field, the existing records have nothing in it. And the first time someone "just tweaks" the pallet calculation, nobody notices that it now rounds the wrong way.

This part covers the four things that turn working code into an app you can put in front of a business:

  1. Permission sets, so people can use it, and only use what they should.
  2. Install codeunits, so the app arrives with sensible data.
  3. Upgrade codeunits, so each new version fixes up the data the old one left behind.
  4. Tests and shipping, so changes are proven before they reach production, and get there safely.

Permission sets

Business Central's security model is simple at its core: a user can do nothing unless a permission set they hold allows it. Microsoft's own permission sets (D365 BASIC, D365 BUS FULL ACCESS and so on) know nothing about your objects. Unless you ship permission sets of your own, nobody except a SUPER user can use your app. The full security model is in permissions in Business Central; here is what you need to build one.

Permission sets are objects

In AL, a permission set is an object, written and versioned with the rest of the code. As throughout the series, complete listings start with the optional namespace line (see part 1); our own objects share the namespace, so they need no using to see each other.

namespace BusinessBoxed.PalletManager;

permissionset 50100 "BBX Pallet View"
{
    Assignable = true;
    Caption = 'Pallet Manager - View';

    Permissions =
        tabledata "BBX Pallet Type" = R,
        table "BBX Pallet Type" = X,
        page "BBX Pallet Type List" = X,
        page "BBX Pallet Type Card" = X,
        page "BBX Pallet Type FactBox" = X,
        report "BBX Pallet Requirements" = X,
        query "BBX Items by Pallet Type" = X,
        codeunit "BBX Pallet Mgt." = X;
}

And the set for people who maintain pallet types, building on the first:

namespace BusinessBoxed.PalletManager;

permissionset 50101 "BBX Pallet Edit"
{
    Assignable = true;
    Caption = 'Pallet Manager - Edit';
    IncludedPermissionSets = "BBX Pallet View";

    Permissions =
        tabledata "BBX Pallet Type" = RIMD,
        page "BBX Pallet Type API" = X,
        query "BBX Item Pallets API" = X,
        xmlport "BBX Pallet Type Import" = X;
}

What each part means

PropertyWhat it doesExample / default
AssignableWhether an administrator can assign the set to a user or security group. Set it to false for "building block" sets that are only meant to be included in other sets.Assignable = true;
CaptionThe readable name administrators see. Translated like any other caption.Caption = 'Pallet Manager - View';
IncludedPermissionSetsOther permission sets this one contains. Lets you build Edit on top of View, so a change to View flows into Edit automatically.IncludedPermissionSets = "BBX Pallet View";
ExcludedPermissionSetsPermission sets whose permissions are taken away from this one.Rarely needed in a small app
PermissionsThe list of object permissions, separated by commas.See below

Keep permission set names short. The name becomes the permission set's ID in the client (the "Role ID"), which is limited to 20 characters.

The permission letters

For tabledata (the data in a table), there are four letters: Read, Insert, Modify and Delete. For every other object type (table, page, report, codeunit, query, xmlport), there is one letter: X for execute, meaning the user can run or open it.

Note the difference between table "BBX Pallet Type" = X and tabledata "BBX Pallet Type" = R. The first lets the user use the table object; the second lets them read the records in it. You almost always need both.

Lowercase letters mean indirect permission: tabledata "Item Ledger Entry" = r means the user can read item ledger entries, but only through code that is itself allowed to give that access, never by opening the table directly. This is how Business Central lets an order clerk post an order (which writes ledger entries) without letting them edit ledger entries by hand.

What about the Item fields?

Our two fields on Item, "BBX Default Pallet Type" and "BBX Units per Pallet", don't need their own permissions. Fields added by a table extension are covered by the base table's permissions, so anyone who can modify items can fill them in. They do, however, need to read BBX Pallet Type to look up a pallet type, which is one reason "BBX Pallet View" is worth assigning broadly.

Let the tools write the first draft

You don't have to type every object. In VS Code, open the command palette (Ctrl+Shift+P), type "permission set" and run AL: Generate permission set containing current extension objects. It produces a permission set with every object in your app, which you then split into sensible sets: View for most people, Edit for the few who maintain the data. Re-run it whenever you add objects, or the new objects will be missing from your sets and the first user to hit one will tell you.

Adding to Microsoft's sets: permission set extensions

If every user who has D365 BASIC should also be able to view pallet types, you can extend Microsoft's set instead of asking administrators to assign yours:

namespace BusinessBoxed.PalletManager;

// Add a using for the namespace of the D365 BASIC permission set:
// press F12 (Go to Definition) on it to see which namespace it is in.

permissionsetextension 50100 "BBX D365 Basic" extends "D365 BASIC"
{
    IncludedPermissionSets = "BBX Pallet View";
}

If a permission set refers to Microsoft objects, for example tabledata Item, it needs the matching using line too (using Microsoft.Inventory.Item;), exactly like a codeunit would. Use permission set extensions sparingly. They are convenient, but they change what a standard permission set means in that tenant, which can surprise an auditor reviewing who can see what.

Codeunits that need more than the user has

Sometimes your code needs to touch data the user isn't allowed to touch directly. A codeunit can declare that with its own Permissions property:

namespace BusinessBoxed.PalletManager;

using Microsoft.Inventory.Ledger;

codeunit 50100 "BBX Pallet Mgt."
{
    Permissions = tabledata "Item Ledger Entry" = r;
    ...
}

Combined with an indirect permission in the user's set, this lets the code read item ledger entries on the user's behalf, for example to work out how many pallets were shipped last month, without giving the user free access to the table.

Entitlements

You will see entitlement objects in Microsoft's code. Entitlements link permissions to licences and are only for apps published on AppSource, so a per-tenant extension like Pallet Manager doesn't use them.

Install codeunits: arriving with data

When Pallet Manager is installed in a company for the first time, the Pallet Type table is empty. The user opens the list and has to invent pallet types before anything works. Better to arrive with the common ones already set up. That is what an install codeunit is for: code that runs when the app is installed.

namespace BusinessBoxed.PalletManager;

using System.Upgrade;

codeunit 50102 "BBX Pallet Install"
{
    Subtype = Install;

    trigger OnInstallAppPerCompany()
    var
        AppInfo: ModuleInfo;
        UpgradeTag: Codeunit "Upgrade Tag";
    begin
        NavApp.GetCurrentModuleInfo(AppInfo);
        if AppInfo.DataVersion() = Version.Create(0, 0, 0, 0) then
            InsertDefaultPalletTypes();

        UpgradeTag.SetAllUpgradeTags();
    end;

    local procedure InsertDefaultPalletTypes()
    var
        UKStandardTxt: Label 'UK standard pallet';
        EuroTxt: Label 'Euro pallet';
        HalfTxt: Label 'Half pallet';
    begin
        InsertPalletType('UK-STD', UKStandardTxt, 1200, 1000, 1000);
        InsertPalletType('EURO', EuroTxt, 1200, 800, 1000);
        InsertPalletType('HALF', HalfTxt, 800, 600, 500);
    end;

    local procedure InsertPalletType(PalletCode: Code[20]; PalletDescription: Text[100]; LengthMm: Integer; WidthMm: Integer; MaxWeightKg: Decimal)
    var
        PalletType: Record "BBX Pallet Type";
    begin
        if PalletType.Get(PalletCode) then
            exit;
        PalletType.Init();
        PalletType.Code := PalletCode;
        PalletType.Description := PalletDescription;
        PalletType.Material := PalletType.Material::Wood;
        PalletType."Length (mm)" := LengthMm;
        PalletType."Width (mm)" := WidthMm;
        PalletType."Max. Weight (kg)" := MaxWeightKg;
        PalletType.Insert(true);
    end;
}

How it works

  • Subtype = Install tells the platform to run this codeunit's install triggers when the app is installed.
  • OnInstallAppPerCompany runs once for each company in the environment. Pallet types are per-company data, so this is the right trigger.
  • OnInstallAppPerDatabase, the other install trigger, runs once for the whole environment. Use it for data that isn't per company, such as a table with DataPerCompany = false.
  • NavApp.GetCurrentModuleInfo gives you information about your own app. Its DataVersion() is the version of the app whose data is in the database: 0.0.0.0 on a genuinely fresh install, or the old version number if the app was uninstalled and is now being reinstalled with its data still there. Checking it stops you inserting defaults on top of a customer's real data after a reinstall.
  • Checking before inserting. if PalletType.Get(PalletCode) then exit; makes the code safe to run twice. Install and upgrade code should always be safe to run twice.
  • using System.Upgrade; under the namespace is there because the "Upgrade Tag" codeunit lives in Microsoft's System.Upgrade namespace. Without namespaces in your own file you can leave it out.
  • UpgradeTag.SetAllUpgradeTags() marks every upgrade step as already done on a fresh install, because a fresh install has no old data to fix. More on that in the next section.

Note that the descriptions are Labels, so they are translated if the app ships in other languages, while the codes (UK-STD, EURO) are data identifiers and stay as they are.

Install code runs when an app is installed, not when a new company is created later. If new companies should also get default pallet types, subscribe to the company initialisation event as well, or give users an action that creates the defaults.

Upgrade codeunits: every version after

Install code handles day one. Upgrade code handles every version after. Whenever a new version of your app changes what the data should look like, an upgrade codeunit fixes up the existing data when that version is installed over the old one.

A realistic example: version 1.0.0.0 of Pallet Manager shipped without the Material field. Version 1.1.0.0 adds it. Every pallet type created under 1.0 now has a blank material, and the report from part 8 groups by material, so all the old pallet types land in a blank group. The upgrade should set the existing records to Wood, which is what all of them were anyway.

namespace BusinessBoxed.PalletManager;

using System.Upgrade;

codeunit 50103 "BBX Pallet Upgrade"
{
    Subtype = Upgrade;

    trigger OnUpgradePerCompany()
    begin
        SetDefaultMaterial();
    end;

    local procedure SetDefaultMaterial()
    var
        PalletType: Record "BBX Pallet Type";
        UpgradeTag: Codeunit "Upgrade Tag";
    begin
        if UpgradeTag.HasUpgradeTag(DefaultMaterialTag()) then
            exit;

        PalletType.SetRange(Material, PalletType.Material::" ");
        PalletType.ModifyAll(Material, PalletType.Material::Wood);

        UpgradeTag.SetUpgradeTag(DefaultMaterialTag());
    end;

    local procedure DefaultMaterialTag(): Code[250]
    begin
        exit('BBX-PalletDefaultMaterial-20261106');
    end;

    [EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", 'OnGetPerCompanyUpgradeTags', '', false, false)]
    local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]])
    begin
        PerCompanyUpgradeTags.Add(DefaultMaterialTag());
    end;
}

Why upgrade tags?

Upgrade code runs every time a new version is installed: 1.1, then 1.2, then 1.3. Without protection, the 1.1 data fix runs again on every one of them, and on the day it no longer makes sense, it quietly damages data. An upgrade tag is a record that says "this step has been done in this company". The pattern is always:

  1. If the tag exists, exit.
  2. Do the work.
  3. Set the tag.

Registering the tag through OnGetPerCompanyUpgradeTags is what lets SetAllUpgradeTags, in our install codeunit, mark it as done on fresh installs, where there is nothing to fix. Give each tag a unique name that includes your prefix, what the step does and a date; it is stored in the database for ever.

The upgrade triggers

TriggerWhen it runsUse it for
OnCheckPreconditionsPerDatabase / PerCompanyFirst, before anything changes.Checking it is safe to upgrade. Calling Error here stops the upgrade, and the old version stays installed.
OnUpgradePerDatabaseOnce for the environment.Data that isn't per company.
OnUpgradePerCompanyOnce for each company.Most data fixes, like ours.
OnValidateUpgradePerDatabase / PerCompanyLast, after the upgrade code.Checking the result. An error here rolls the whole upgrade back.

The upgrade runs in one transaction: if any trigger errors, nothing is changed and the old version stays. That is reassuring, but it also means a slow upgrade over a big table holds everything up, so keep upgrade code efficient. ModifyAll, as above, is far quicker than looping through records one at a time when you don't need the table's triggers to run.

Check the version before the upgrade with NavApp.GetCurrentModuleInfo and DataVersion(), as in the install codeunit, but use upgrade tags to decide whether a step runs. Tags are more reliable than version comparisons, especially when versions are skipped.

Retiring a field properly

Sooner or later you will want to remove something. You can't simply delete a field from a table in a published app: in production, Business Central won't accept a new version that drops a field, because that would destroy data. You retire it in stages, with the obsolete properties:

        field(9; "Pallet Height"; Integer)
        {
            Caption = 'Pallet Height';
            DataClassification = CustomerContent;
            ObsoleteState = Pending;
            ObsoleteReason = 'Replaced by the Height (mm) field.';
            ObsoleteTag = '1.2';
        }
  • ObsoleteState = Pending keeps the field and its data, but the compiler warns anyone who still uses it. Use this in the version where you introduce the replacement, and move the data across in an upgrade codeunit.
  • ObsoleteState = Removed, in a later version, means the field can no longer be used in code. The definition stays in your source.
  • ObsoleteReason says why, and what to use instead. Developers see it in their warnings.
  • ObsoleteTag records the version in which it became obsolete.

The full process, including tables, enums and procedures, is in changing a live extension without losing data.

Tests

Every change you make to Pallet Manager from now on risks breaking something that worked. Automated tests are how you find out in seconds, not from a warehouse manager three weeks later.

A separate test app

Tests live in their own app, "Pallet Manager Tests", with its own ID range (50150 to 50199), never in the app you ship. The test app depends on Pallet Manager and on Microsoft's test libraries. Its app.json dependencies look like this:

  "dependencies": [
    {
      "id": "{palletManagerAppId}",
      "name": "Pallet Manager",
      "publisher": "Business Boxed Limited",
      "version": "1.0.0.0"
    },
    {
      "id": "{libraryAssertAppId}",
      "name": "Library Assert",
      "publisher": "Microsoft",
      "version": "{bcVersion}"
    },
    {
      "id": "{testsTestLibrariesAppId}",
      "name": "Tests-TestLibraries",
      "publisher": "Microsoft",
      "version": "{bcVersion}"
    }
  ]

Take the app IDs and versions from the symbols in your .alpackages folder, or let VS Code's code completion fill them in. Tests run in an environment with Microsoft's test toolkit installed: usually a Docker container created with BcContainerHelper, or the pipeline described below. Never install test apps in production.

If you use namespaces, give the test app its own, BusinessBoxed.PalletManager.Test, and add using BusinessBoxed.PalletManager; to each test codeunit so it can see the app's objects, as in the listing below. If you don't use namespaces in the app, leave them out of the tests as well.

The procedure we are testing

In part 7, "BBX Pallet Mgt." got a CalcPalletsNeeded procedure: given an item and a quantity, it returns how many whole pallets are needed, using the item's "BBX Units per Pallet" and rounding up, and it raises an error if the item has no units per pallet set. That is exactly the kind of rule worth pinning down with tests, because "round up" quietly turning into "round to nearest" would under-order pallets for every odd quantity.

A test codeunit

namespace BusinessBoxed.PalletManager.Test;

using BusinessBoxed.PalletManager;
using Microsoft.Inventory.Item;
using System.TestLibraries.Utilities;
// "Library - Inventory": add a using for its namespace;
// press F12 (Go to Definition) on it to see which namespace it is in.

codeunit 50150 "BBX Pallet Mgt. Tests"
{
    Subtype = Test;
    TestPermissions = Disabled;

    var
        Assert: Codeunit Assert;
        LibraryInventory: Codeunit "Library - Inventory";
        PalletMgt: Codeunit "BBX Pallet Mgt.";
        RoundsUpMsg: Label 'Pallets needed should round up to whole pallets.', Locked = true;
        ExactMsg: Label 'An exact multiple should need no extra pallet.', Locked = true;

    [Test]
    procedure PalletsNeededRoundsUp()
    var
        Item: Record Item;
        PalletsNeeded: Integer;
    begin
        // [GIVEN] An item that fits 40 units on a pallet
        CreateItemWithUnitsPerPallet(Item, 40);

        // [WHEN] We work out the pallets needed for 81 units
        PalletsNeeded := PalletMgt.CalcPalletsNeeded(Item, 81);

        // [THEN] 3 pallets are needed, not 2
        Assert.AreEqual(3, PalletsNeeded, RoundsUpMsg);
    end;

    [Test]
    procedure PalletsNeededForExactMultiple()
    var
        Item: Record Item;
    begin
        // [GIVEN] An item that fits 40 units on a pallet
        CreateItemWithUnitsPerPallet(Item, 40);

        // [WHEN] We work out the pallets needed for 80 units
        // [THEN] Exactly 2 pallets are needed
        Assert.AreEqual(2, PalletMgt.CalcPalletsNeeded(Item, 80), ExactMsg);
    end;

    [Test]
    procedure PalletsNeededFailsWithoutUnitsPerPallet()
    var
        Item: Record Item;
    begin
        // [GIVEN] An item with no units per pallet set
        CreateItemWithUnitsPerPallet(Item, 0);

        // [WHEN] We work out the pallets needed
        asserterror PalletMgt.CalcPalletsNeeded(Item, 10);

        // [THEN] An error is raised
        Assert.ExpectedErrorCode('Dialog');
    end;

    local procedure CreateItemWithUnitsPerPallet(var Item: Record Item; UnitsPerPallet: Integer)
    begin
        LibraryInventory.CreateItem(Item);
        Item."BBX Units per Pallet" := UnitsPerPallet;
        Item.Modify(true);
    end;
}

What each part means

  • Subtype = Test makes this a test codeunit. Only test codeunits can contain [Test] procedures.
  • [Test] marks a procedure as a test. Each one runs separately, and it passes unless something errors.
  • TestPermissions = Disabled runs the tests without permission checks. To prove your permission sets are complete, write separate tests that run with restricted permissions.
  • Given, When, Then comments describe the test in plain English: the situation, the action, the expected result. Anyone can read what is being proven without reading the code.
  • The Assert codeunit, from Microsoft's Library Assert app, does the checking: AreEqual, AreNotEqual, IsTrue, IsFalse, RecordIsEmpty, ExpectedError and more. When a check fails, the test fails with your message.
  • asserterror runs a statement that is expected to fail. If it doesn't fail, the test fails. Assert.ExpectedErrorCode('Dialog') then checks it was an ordinary Error call; Assert.ExpectedError can check the message text instead.
  • Library codeunits such as "Library - Inventory" create realistic test data in one line. LibraryInventory.CreateItem gives you a valid item with a unit of measure and posting groups, which would take twenty lines to build yourself.
  • Each test sets up its own data. The test runner rolls back each test's changes afterwards, so tests don't depend on each other or on what's already in the company.

Handling messages and confirmations

If the code under test shows a Message or asks a Confirm question, the test can't click a button. It fails, unless you provide a handler that answers for it. The ConfirmAndBlockPalletType procedure from part 7 asks "Block pallet type ...?" and then confirms with a message. The test below goes in the same codeunit, with a BlockedMsg label and a small CreatePalletType helper added alongside the others:

    [Test]
    [HandlerFunctions('ConfirmYesHandler,MessageHandler')]
    procedure BlockingPalletTypeAsksFirst()
    var
        PalletType: Record "BBX Pallet Type";
    begin
        // [GIVEN] A pallet type that is not blocked
        CreatePalletType(PalletType);

        // [WHEN] The user blocks it and answers Yes
        PalletMgt.ConfirmAndBlockPalletType(PalletType);

        // [THEN] It is blocked
        PalletType.Get(PalletType.Code);
        Assert.IsTrue(PalletType.Blocked, BlockedMsg);
    end;

    [ConfirmHandler]
    procedure ConfirmYesHandler(Question: Text[1024]; var Reply: Boolean)
    begin
        Reply := true;
    end;

    [MessageHandler]
    procedure MessageHandler(Message: Text[1024])
    begin
    end;

The [HandlerFunctions] attribute lists the handlers this test expects to be used. A handler that is listed but never called also fails the test, which is useful: it proves the confirmation was actually asked.

For more patterns (library codeunits of your own, test isolation, testing pages with TestPage variables), see AL test patterns.

Shipping

Version numbers

Every build you ship needs a higher version in app.json than the one installed. The format is major.minor.build.revision:

  "version": "1.1.0.0"
  • Major for big changes, especially anything that needs a planned upgrade.
  • Minor for new features, such as the Material field in 1.1.
  • Build and revision for fixes, or let your pipeline set them automatically.

Business Central won't install a version over the same or a higher one, so a forgotten version bump is the most common reason an upload "doesn't work".

Per-tenant extensions in production

Pallet Manager is a per-tenant extension (PTE): built for one customer and uploaded to their tenant, not sold on AppSource. The difference matters for how it is managed, as explained in Global, PTE or Dev. In particular, never publish from VS Code to production. VS Code publishes to sandboxes as a development extension; production gets the built .app file, uploaded deliberately.

To upload it:

  1. Build the app (Ctrl+Shift+B in VS Code, or let the pipeline build it) to get the .app file.
  2. In the production environment, open Extension Management, choose Manage, then Upload Extension.
  3. Choose the file. Under Deploy to, choose Current version to install now, or the next minor or major version to have it installed with the next Business Central update. Accept the terms and choose Deploy.
  4. Open the Extension Deployment Status page to watch it install and read any errors.

Every new version follows the same route. The install or upgrade codeunits run as part of the deployment.

Schema sync: why production is stricter

In your sandbox, launch.json lets you set schemaUpdateMode to Synchronize, Recreate or ForceSync. Recreate throws away the data in your tables when their structure changes, which is fine for a sandbox full of test data.

Production has no such options. A new version may only make non-destructive changes to the database: add tables, add fields, make text fields longer. If the new version would delete a field, change a field's type, shorten a field, or change a primary key, the deployment fails and the old version stays. That is the platform protecting your customer's data, and it is why the obsolete process above exists. Plan table design carefully in version 1.0; it is far harder to change once real data is in it.

Automate it

Building the app, running the tests and producing the .app file by hand is fine for your first extension and a liability by your fifth. AL-Go for GitHub (or a similar pipeline in Azure DevOps) builds every change, runs the test app, checks the code with the analysers, numbers the version and gives you a ready-to-upload file. The tests from this part are what make that pipeline worth having. See AL-Go, test codeunits and a pipeline you can trust.

The go-live checklist

  • The version number in app.json is higher than anything installed.
  • The app compiles with no warnings from CodeCop, UICop and PerTenantExtensionCop that you haven't consciously accepted.
  • All tests pass, in an environment on the same Business Central version as production.
  • The permission sets include every object (regenerate and compare), and you have tested as a user who holds only "BBX Pallet View", and as one who holds only "BBX Pallet Edit".
  • Install code is safe to run twice, and checks DataVersion.
  • Upgrade code uses upgrade tags, and you have tested it by installing the previous version, adding data, then upgrading to the new one in a sandbox copy of production.
  • No destructive schema changes: no fields removed, retyped or shortened.
  • Every field has a DataClassification set, every user-facing message is a Label, every page field has a tooltip.
  • Namespaces are used consistently: either every file has a namespace line, or none does. Mixing the two works, but it makes the code harder to follow.
  • The source is committed and tagged with the version, so you can always rebuild exactly what is in production.
  • Someone other than the developer has used it in a sandbox and signed it off.
  • The deployment is scheduled for a quiet time, and users know what's coming.

Common mistakes

  • Testing only as SUPER. Everything works for the developer and nothing works for users.
  • Forgetting new objects in the permission sets. Regenerate and compare every time you add an object.
  • Giving everyone RIMD. Most people only need to view reference data like pallet types.
  • Install code that inserts without checking. It fails, or duplicates data, on a reinstall.
  • Upgrade code without upgrade tags. It runs again on every future version.
  • Looping record by record in upgrade code when ModifyAll would do. On a big table the deployment slows to a crawl.
  • Deleting or retyping a field. Production refuses the deployment. Obsolete it instead.
  • Tests that depend on existing data. They pass in one company and fail in the next. Each test creates what it needs.
  • Shipping the test app to production. Tests belong in sandboxes and pipelines only.
  • Publishing from VS Code to production. Upload a built, versioned, tested .app file instead.

That's the series

Over ten parts, you have built a complete, small but real Business Central app: a project set up properly, a table with the right data types, keys, FlowFields and triggers, fields on a standard table, an enum others can extend, list, card and FactBox pages with actions, page extensions on standard pages, codeunits that work with records and events, a report, a query and an XMLport, a versioned API, permission sets, install and upgrade code, tests and a safe route to production.

That covers the basics every Business Central developer uses every day. Everything more advanced (posting routines, document patterns, interfaces, telemetry, performance tuning) builds on exactly these pieces. The best next step is to build something small that your own business actually needs, using this series as the checklist.

Previous: part 9, API pages and API queries. Back to the series contents, where every part is listed.

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