All articles

Business Central

Keeping a Large AL Extension Upgrade-Clean, Twice a Year

Upgrade code is the smallest part of surviving Business Central's two major releases a year. Here is what our AL repositories actually do — the calendar, the ruleset, the one-tag-per-migration rule — and the two incidents that shaped them.

A cluster of dark navy rectangular modules on a pale grey studio background, with one module sliding into its slot along two thin rails and a teal glow along the seam — a clean fit, nothing forced.

Keeping a Large AL Extension Upgrade-Clean, Twice a Year

A Business Central extension stays upgrade-clean when three things are true: it compiles against the next minor and the next major before Microsoft ships them, its upgrade codeunit is too small to fail, and every deprecation Microsoft announces shows up as a warning in your own build, with a written reason, a wave before it becomes an error. Upgrade code is the smallest part of that. Most of the work is a calendar and a ruleset.

We maintain several AL applications this way — an AppSource e-invoicing app with 40 test codeunits and 433 tests, and a per-tenant project time-sheet extension of some eighty objects. What follows is what those repositories actually do.

What does Business Central’s update rhythm demand from an extension?

Two major updates a year, April and October, with a minor update in the months between. A preview version is available about a month before each major so partners can validate their extensions on it. Once the major is generally available, administrators have a five-month update period to schedule it, then a one-month grace period — September or March — during which the update can no longer be pushed back. After that comes the enforced update period: any extension that makes the update fail may be uninstalled automatically so the environment can move on.

One detail changes how you plan: Microsoft’s compatibility check before a major is technical. It confirms your app still compiles and installs on the new version. Whether it still does the right thing is your responsibility, and invisible to that check. So the question is not whether you will survive the wave. It is whether you find out in February what breaks in April, or on the day a customer’s environment updates.

Why is the upgrade codeunit the most dangerous code you own?

Because an error in OnUpgradePerCompany cancels the extension upgrade. Not the migration — the entire version. Every tenant holding data is then stuck on the old version until you ship a fix.

A cosmetic label backfill on our e-invoicing app’s upgrade path read Microsoft’s E-Document table to relabel a few records. The platform refused the read with a permission error, even though the codeunit declared that permission; the root cause was never established. A label migration was blocking every tenant from taking a new version. Worse, our pipeline had been red for six days on a deployment channel nobody used, and the real failure hid under that noise. A signal that screams non-stop signals nothing.

The fix was structural. The upgrade codeunit now touches only our own tables and the Job Queue. Every migration that reads or writes a base table moved out to a background task, armed once per company and run in its own Codeunit.Run, so one failing migration can neither starve another nor cost a version. The Permissions declaration on the upgrade codeunit was removed rather than left dangling. That is a non-declaration, not a tripwire: the codeunit no longer grants a permission it no longer uses, and the compiler will not flag a base-table read that someone puts back on that path. What catches it is the test suite — two tests assert that the upgrade path leaves the E-Document records untouched, so a re-added read fails in CI, not in a customer’s upgrade window.

The rule we keep: anything on the upgrade path must be able to fail without costing a version upgrade. If it cannot, it does not belong there.

How do you make a migration run exactly once — and never silently zero times?

With upgrade tags, applied more strictly than the documentation requires. Microsoft’s Upgrade Tag codeunit gives you HasUpgradeTag, SetUpgradeTag, and an event to register your tags so a new company gets them stamped as already done. The documented pattern is: check the tag, do the work, set the tag. Our additions:

  • One tag per migration, never shared. One migration having completed must never be read as another having completed.
  • The tag is dated, namespaced, and a plain literal — never a translatable label.
  • Tag check, work, and tag write are one atomic unit in one procedure. Codeunit.Run commits only when no error occurs, so a failure leaves the tag unset and the migration is retried the next time the task is armed.
  • There is deliberately no “run all migrations” helper. One existed and was deleted in review: a procedure that fans out to every backfill is the natural place to register a new one, and exactly what production does not call. A third migration wired only there would pass every test and never run on a single tenant.

That last rule came from a second incident. After moving the migrations off the upgrade path, we parked them on a recurring job — whose Job Queue entry is created On Hold by design. On a tenant that upgrades, which is exactly the population that has data to migrate, nothing ever drove them. No error, no trace. Silence disguised as functioning. The fix was written after reading the Job Queue source in Microsoft’s public BCApps repository, and it is pinned by a test that deletes the tags through the Upgrade Tag Library and asserts one tag per migration.

How do you hear about a breaking change before the wave does?

You compile against it, on a calendar tied to Microsoft’s.

AL-Go for GitHub ships three probe workflows — Test Current, Test Next Minor, Test Next Major — each pointed at a different artifact. In our repositories the three settings files differ by exactly one line: ////latest, ////nextminor, ////nextmajor. The cadence we run is Test Current and Test Next Minor weekly, Test Next Major weekly during February–March and August–September — the two windows closest to what will actually ship, with time left to react — and Update AL-Go System Files weekly so the harness itself does not rot. On the timesheet app all four are scheduled that way today. On the e-invoicing app only Test Current and the system-files update are scheduled; Test Next Minor and Test Next Major still run on demand, which is the gap we are closing next.

The second half is making deprecations loud. Microsoft’s timeline is that code tagged obsolete in version N is still present in N+1 and N+2 and removed at the earliest in N+3 — at least twelve months. So in the time-sheet app’s ruleset, AL0432 (“you reference a base object Microsoft has marked pending obsoletion”) is pinned explicitly to Warning, with a written justification, so nobody can silently lower it. A rule that flags a runtime version falling behind the platform is on for the same reason. AppSourceCop can go further with obsoleteTagMinAllowedMajorMinor: set it two versions back and any reference to something Microsoft could remove in the next release becomes an error today. Microsoft recommends switching it on for a pass rather than permanently, because of its cost on large projects.

Two more deprecations are killed at manifest level rather than by grep: NoImplicitWith turns an ambiguous with into a compile error instead of a runtime surprise when Microsoft adds a field to a base table, and NoPromotedActionProperties bans the old promoted-action properties outright.

The same discipline applies to your own surface. For an AppSource app you do not delete: AppSourceCop refuses removed, retyped or renamed tables and fields outright. You mark ObsoleteState = Pending with a reason and the version tag, ship the alternative, and move to Removed at least one major later.

Which coding rules actually pay off at upgrade time?

The ones that assume Microsoft will change the base application, because it will.

Additive only. Table extensions, page extensions and event subscribers against base objects; our own tables for our own data, and as few as possible: some of the AL extensions worth knowing exist to delete code you would otherwise own. Both apps run with a single namespace root and a mandatory object affix, and the e-invoicing app has 22 event subscribers against zero modified base logic.

Run triggers by default. Insert(true), Modify(true), Validate rather than :=. The reason is upgrade-specific: it honours the table’s business logic present and future. When Microsoft adds an OnValidate to a field you assign, code that validated keeps working; code that assigned directly quietly skips the new logic. A bypass has to carry a comment saying why, and converting an old (false) to (true) is a behaviour change gated on tests, never swept.

Access = Internal on install and upgrade codeunits, enforced as an error in the ruleset rather than as a habit.

One ruleset for the organisation, hosted remotely and inherited by every repository, with local overrides that each carry a justification — sixty-eight of them in the time-sheet app. Analyzer configuration lives in git, not in a developer’s editor, so there is no per-developer drift and the local gate is at least as strict as CI.

And one lesson from the version-bump history: raising runtime in app.json is a deploy-time constraint, not just a compile-time one. We raised it to 17.0 nine days after the April 2026 major went generally available; the QA sandbox had not taken the update yet, publishing failed, and we rolled back the same day. Pin application to the major floor, not to a minor.

The Asio Services way

Upgrade hygiene is not a phase; it is the default state of the repository. Every change starts as an issue, lands with a test, passes zero-warning analyzers locally before push, and gets compiled against the next wave before Microsoft ships it. Most of that is cheap. The alternative is an enforced update that uninstalls your app on a Tuesday. It is also why we push back on customizations that should never have been written: every object you own is one more thing you renew twice a year.

If you inherited an extension whose last upgrade was a bad week, or you are planning Business Central development that has to still be running in five waves, start with our clarity form. We will tell you what sits on the upgrade path and what should not.

FAQ

What is an upgrade codeunit in Business Central?

A codeunit with Subtype = Upgrade whose triggers — OnUpgradePerCompany and OnUpgradePerDatabase — run when a new version of your extension is installed over an old one. An error in either cancels the extension upgrade, so it should only do what cannot wait and only touch what you own.

How do upgrade tags work?

The Upgrade Tag codeunit records, per company, that a named piece of upgrade code has run. You check the tag, do the work, set the tag, and register it in the OnGetPerCompanyUpgradeTags event so new companies start with it already set. One tag per migration keeps migrations independent.

How long before Microsoft removes obsolete code?

At least twelve months. Code tagged obsolete in version N is still present in N+1 and N+2 and removed at the earliest in N+3. AppSourceCop’s obsoleteTagMinAllowedMajorMinor setting finds the references that could break at the next release.

What happens if my extension blocks a major update?

During the enforced update period it may be uninstalled automatically so the environment can update. Its data is kept and can be recovered by installing a compatible version afterwards. If it blocks a critical security update, it may be uninstalled within 14 days.

Is your Business Central the problem, or the symptom?

We audit what you actually run, name what is worth keeping, and kill the rest. One conversation is usually enough to tell which one you are dealing with.

Start with clarity