Morten Laske AI × Business Central
← Writing

The breaking changes you can't see coming (and how AppSourceCop saves you)

The breaking changes you can't see coming (and how AppSourceCop saves you)
  1. Your AppSource dependency is a live product, and AVS0118 is the proof
  2. The breaking changes you can't see coming (and how AppSourceCop saves you)

In a normal codebase, deleting an unused public method is good hygiene. Renaming a field for clarity is a Tuesday refactor. Removing an action nobody clicks is housekeeping. In an AppSource extension, all three are breaking changes — and the validation gate that catches them does so late, after every local build was green and you had mentally shipped.

My stance: an AppSource extension’s public surface is an append-only ledger. You add to it, you mark entries dead, you never tear a page out — and the sooner that becomes a reflex, the less LC0034 ever ambushes you.

The rule underneath is simple and unforgiving: once a symbol is public, dependent extensions may be built on it, so you can never remove or rename it. AppSourceCop enforces this with LC0034 — “missing in current extension, will break dependent extensions.”

What counts as a public symbol

More than you think. The things you cannot remove or rename:

  • Page controls, groups, actions, and parts
  • Table fields and keys
  • Enum values
  • Public procedures
  • Entire objects

Anything another extension could reference by name is a contract. Refactors that rename or delete any of the above are breaking, even when you have no consumer — because AppSource has to assume someone does.

Major version bumps are not the escape hatch

The reflex is “fine, I’ll bump the major version and break compatibility on purpose.” It does not work here. The extension’s version is coupled to the BC platform version; you do not get to declare a clean break the way you would with your own semver’d library. AppSourceCop will reject the breaking change regardless of how you number it.

The pattern: keep the old, add the new

The correct move is the one that feels like clutter and is actually discipline. You never remove the old symbol — you deprecate it in place and add the replacement beside it.

For a field or procedure, mark it obsolete and leave it:

field(50100; "Old Status"; Enum "My Status")
{
    ObsoleteState = Pending;
    ObsoleteReason = 'Replaced by "Processing Status". Removed no earlier than v3.';
    ObsoleteTag = '2.4';
}
field(50101; "Processing Status"; Enum "My Status")
{
    Caption = 'Processing Status';
}

For a page control you no longer want shown, hide it instead of deleting it:

field("Old Status"; Rec."Old Status")
{
    Visible = false;  // gone from the UI, still present in the contract
}

The old symbol stays in the surface area, invisible or deprecated; the new one carries the behavior. Consumers that referenced the old name keep compiling. You get your cleaner API and you do not break anyone.

✗ remove / rename
"Old Status"
LC0034 — missing symbol
breaks dependent extensions
✓ append-only
"Old Status"ObsoleteState = Pending
"Processing Status"new, beside it
old names still resolve ✓
The public surface is an append-only ledger. You never tear a page out — you mark the old entry dead and add the new one beside it.

Moving data across the deprecation

Hiding the old field is half the job; the data is still in it. An upgrade codeunit is where you carry values from the deprecated field to its replacement, so the new field is populated for existing records on install:

codeunit 50190 "Upgrade Status Field"
{
    Subtype = Upgrade;
    trigger OnUpgradePerCompany()
    var
        Rec: Record "My Table";
    begin
        if Rec.FindSet(true) then
            repeat
                Rec."Processing Status" := Rec."Old Status";
                Rec.Modify();
            until Rec.Next() = 0;
    end;
}

The deprecated field stays in the schema (you cannot remove it), but after the upgrade nothing reads it. It becomes a dead column you carry for compatibility — which is exactly the trade the append-only rule asks of you.

Pending, then Removed — the slow lane

Deprecation is a lifecycle, not a switch. ObsoleteState = Pending keeps the symbol fully present and compiling — it just signals intent and lets the compiler warn consumers. Only much later, after dependents have had real time (think versions, not weeks) to migrate, does a symbol move to ObsoleteState = Removed. And even “Removed” is a careful, announced step, not a delete — you are still telling the platform a contract is ending, on a timeline, rather than yanking it.

The discipline this enforces is patience. The cleanup you want today is a decision you get to announce today and execute later. Trying to compress that into one release is precisely what trips LC0034. Mark it Pending, ship the replacement, and let the calendar do the removal.

Why it bites late

The reason this one stings is timing. AppSourceCop’s breaking-change check tends to surface in the AppSource validation pass — late in the pipeline, after local builds are green and you have mentally shipped. A rename that compiled fine all week gets rejected at the gate. And the pressure is sharper right now: several v27 optional features flip to mandatory in BC28, so refactors you shelved “for when things settle” suddenly have to ship — making this exactly the wrong moment to discover your cleanup is a breaking change.

So move the check left: treat “did I remove or rename a public symbol?” as a code-review question, not a validation surprise. Before you delete or rename anything in the list above, the question is not “is it used?” — it is “is it public?” If yes, deprecate beside, never remove.

The mental model

An AppSource extension’s public surface is an append-only ledger. You add to it; you mark entries dead; you never tear a page out. Internally you can build the cleanest replacement you like — as long as the old names still resolve. Once that becomes a reflex, LC0034 stops being a late-stage ambush and becomes something you simply never trigger.

This closes the short AppSource series. It opened with Core as a live product — the other place AppSource behaves nothing like the mental model you brought to it.

Found this useful? Share on LinkedIn · email me a correction or follow-up.

Related