Add P0 event and interface compatibility knowledge (#98)

* Add P0 extensibility compatibility knowledge

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 645349fd-1892-48f3-8a84-db77d6abd1c3

* Correct event compatibility guidance

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 645349fd-1892-48f3-8a84-db77d6abd1c3

---------

Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-07-14 12:51:59 +02:00 committed by GitHub
parent 078b869e33
commit 363f08f47e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 331 additions and 32 deletions

View file

@ -1,21 +1,14 @@
// Demonstration-only AL. Not compiled by CI; illustrates the article.
// Demonstration-only AL. Version 1 exposed PostDocument(SalesHeader).
codeunit 50251 "Param Append Bad Sample"
{
procedure PostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean)
var
IsHandled: Boolean;
procedure PostDocument(var SalesHeader: Record "Sales Header")
begin
IsHandled := false;
// Anti-pattern: 'CalledFromBatch' was inserted before the existing
// IsHandled parameter, shifting it and breaking the argument positions
// every existing subscriber relied on.
OnBeforePostDocument(SalesHeader, CalledFromBatch, IsHandled);
if IsHandled then
exit;
// Existing callers cannot supply the newly required argument.
OnBeforePostDocument(SalesHeader);
end;
[IntegrationEvent(false, false)]
local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean; var IsHandled: Boolean)
procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean)
begin
end;
}

View file

@ -1,4 +1,4 @@
// Demonstration-only AL. Not compiled by CI; illustrates the article.
// Demonstration-only AL. Version 1 had SalesHeader and IsHandled parameters.
codeunit 50250 "Param Append Good Sample"
{
procedure PostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean)
@ -6,15 +6,24 @@ codeunit 50250 "Param Append Good Sample"
IsHandled: Boolean;
begin
IsHandled := false;
// The new 'CalledFromBatch' parameter was appended at the end of the
// existing signature, so existing subscribers needed no re-mapping.
OnBeforePostDocument(SalesHeader, IsHandled, CalledFromBatch);
// Subscribers bind by name, so the new parameter can sit between the
// existing parameters without breaking subscribers that omit it.
OnBeforePostDocument(SalesHeader, CalledFromBatch, IsHandled);
if IsHandled then
exit;
end;
[IntegrationEvent(false, false)]
local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean; CalledFromBatch: Boolean)
local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; CalledFromBatch: Boolean; var IsHandled: Boolean)
begin
end;
}
codeunit 50252 "Existing Param Subscriber"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Param Append Good Sample", 'OnBeforePostDocument', '', false, false)]
local procedure OnBeforePostDocument(var SalesHeader: Record "Sales Header"; var IsHandled: Boolean)
begin
IsHandled := SalesHeader."No." = '';
end;
}

View file

@ -1,26 +1,26 @@
---
bc-version: [all]
domain: events
keywords: [event-parameters, signature, backward-compatibility, append, onbefore, integration-event, versioning]
keywords: [event-parameters, signature, backward-compatibility, public-event, local-event, internal-event, appsourcecop, as0024, as0025]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Add new event parameters at the end
# Event parameter additions depend on publisher access, not position
## Description
Adding a parameter to an existing event publisher changes its signature. Appending the new parameter at the end of the parameter list keeps the change easy to review and track: existing subscribers still bind to the leading parameters, and the diff is a single clean addition. Inserting a parameter in the middle makes diffs noisy and harder to review, and obscures the history of how the signature evolved. New parameters belong after the existing ones.
Event subscribers bind publisher parameters by name and can omit parameters they do not use. A `local` or `internal` Business or Integration event can therefore gain a parameter at any position without breaking subscriber-only consumers; appending is not a compatibility requirement. A public event is also a public procedure that dependent extensions can raise, so adding a required parameter anywhere breaks callers under AppSourceCop AS0024.
## Best Practice
When extending an existing publisher, append the new parameter after all existing ones, including after a trailing `var IsHandled: Boolean` when present. Subscribers that already match keep working against the leading parameters, and the change stays a one-line addition that is trivial to review.
Add a parameter directly only when the shipped event publisher is `local` or `internal`. Place it where the signature is clearest; existing subscribers continue binding the parameters they name. For a public event, keep the original publisher unchanged and introduce a new event with the expanded contract.
See sample: `add-new-event-parameters-at-the-end.good.al`.
## Anti Pattern
Inserting a new parameter in the middle of an existing event's signature, shifting every subsequent parameter and making the change noisy and harder to review. Detection: a changed event signature where an added parameter appears before existing parameters rather than at the tail of the list.
Appending a parameter to a public event and assuming its position makes the change compatible. Existing external callers still lack the new required argument. Conversely, do not flag a parameter inserted among existing parameters on a `local` or `internal` Business or Integration event merely because it was not appended.
See sample: `add-new-event-parameters-at-the-end.bad.al`.

View file

@ -0,0 +1,14 @@
// Demonstration-only AL. Version 1 used [IntegrationEvent(true, true, false)].
codeunit 50531 "Shipment Events Bad"
{
procedure NotifyShipment(ShipmentNo: Code[20])
begin
OnShipmentCreated(ShipmentNo);
end;
// Version 2 mutates all three contract-significant arguments in place.
[IntegrationEvent(false, false, true)]
local procedure OnShipmentCreated(ShipmentNo: Code[20])
begin
end;
}

View file

@ -0,0 +1,21 @@
// Demonstration-only AL. The Isolated argument requires runtime 9.0 / BC20.
codeunit 50530 "Shipment Events"
{
procedure NotifyShipment(ShipmentNo: Code[20])
begin
OnShipmentCreated(ShipmentNo);
OnShipmentCreatedIsolated(ShipmentNo);
end;
// Preserve the shipped attribute contract.
[IntegrationEvent(true, true, false)]
local procedure OnShipmentCreated(ShipmentNo: Code[20])
begin
end;
// Publish a new event for different isolation and sender semantics.
[IntegrationEvent(false, false, true)]
local procedure OnShipmentCreatedIsolated(ShipmentNo: Code[20])
begin
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: events
keywords: [event-attribute, includesender, globalvaraccess, isolated-event, compatibility, integration-event, business-event, appsourcecop, as0021, as0101]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not change shipped event attribute flags
## Description
`IncludeSender` and, on Integration events, `GlobalVarAccess` have been event-contract flags since runtime 1.0. Removing sender or global access breaks subscribers, so AppSourceCop AS0021 prevents changing those flags from `true` to `false`. On runtime 9.0 and later (Business Central 2022 release wave 1, BC20), `Isolated` also controls transaction, error, and rollback behavior; AS0101 prevents adding, removing, or changing that argument.
## Best Practice
Keep every available attribute argument exactly as shipped. If new subscribers need different sender/global exposure, publish a new event with the desired flags. Apply the same rule to `Isolated` only on BC20 or later, where that argument exists. Raise both events while the original contract is supported, and choose preferred flags only when designing a new event.
See sample: `do-not-change-shipped-event-attribute-flags.good.al`.
## Anti Pattern
Changing a shipped event's `IncludeSender` or `GlobalVarAccess` to modernize its design, including replacing `IncludeSender` with an explicit parameter. On BC20 or later, adding, removing, or toggling `Isolated` is equally contract-significant. Even a change that leaves old subscribers compiling can alter observable execution or exposure; version the event instead.
See sample: `do-not-change-shipped-event-attribute-flags.bad.al`.

View file

@ -7,20 +7,20 @@ countries: [w1]
application-area: [all]
---
# Prefer this over IncludeSender in codeunit events
# Prefer this over IncludeSender in new codeunit events
## Description
Some publishers set `IncludeSender` to `true` on `[IntegrationEvent]` or `[BusinessEvent]` so subscribers receive the publishing object as an implicit sender parameter. From Business Central 2024 release wave 2, a codeunit can instead pass itself explicitly with the `this` keyword as a normal, strongly-typed `Sender` parameter. Explicit passing is clearer at both the publisher and the subscriber: the sender appears in the signature, it is concretely typed to the publishing codeunit, and it avoids the implicit-parameter mechanics of `IncludeSender`. Reserve `IncludeSender = true` for cases where the sender genuinely cannot be passed explicitly. This guidance applies to code targeting Business Central 2024 release wave 2 or later, where the `this` keyword is available.
When designing a new publisher, setting `IncludeSender` to `true` on `[IntegrationEvent]` or `[BusinessEvent]` gives subscribers the publishing object as an implicit sender parameter. From Business Central 2024 release wave 2, a codeunit can instead pass itself explicitly with the `this` keyword as a normal, strongly-typed `Sender` parameter. Explicit passing makes the sender visible and typed in the signature. This is new-event design guidance only: never change `IncludeSender` on an event that has already shipped.
## Best Practice
Declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "…"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly.
For a new event, declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "…"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly.
See sample: `prefer-this-over-includesender-in-codeunit-events.good.al`.
## Anti Pattern
Relying on `[IntegrationEvent(true, …)]` solely to hand subscribers the publisher instance, where a codeunit could pass `this` explicitly as a typed parameter. Detection: `IncludeSender = true` on a codeunit event whose only purpose is to expose the sender, in code targeting Business Central 2024 release wave 2 or later.
Designing a new codeunit event with `[IntegrationEvent(true, …)]` solely to hand subscribers the publisher instance, where `this` could be passed explicitly as a typed parameter. Do not apply this rule by mutating a shipped event's attribute flags.
See sample: `prefer-this-over-includesender-in-codeunit-events.bad.al`.

View file

@ -0,0 +1,15 @@
// Demonstration-only AL. Version 1 exposed var Score as an Integer.
codeunit 50521 "Customer Scoring Events Bad"
{
procedure ScoreCustomer(CustomerNo: Code[20]; ScoreText: Text)
begin
OnCustomerScored(CustomerNo, ScoreText);
end;
// 'local' limits raising, not subscription. Renaming Score to ScoreText,
// changing its type, and removing var all break existing subscribers.
[IntegrationEvent(false, false)]
local procedure OnCustomerScored(CustomerNo: Code[20]; ScoreText: Text)
begin
end;
}

View file

@ -0,0 +1,24 @@
// Demonstration-only AL. Version 1 had CustomerNo and var Score parameters.
codeunit 50520 "Customer Scoring Events"
{
procedure ScoreCustomer(CustomerNo: Code[20]; Reason: Text; var Score: Integer)
begin
OnCustomerScored(CustomerNo, Reason, Score);
end;
// Adding Reason between existing parameters preserves subscriber bindings.
[IntegrationEvent(false, false)]
local procedure OnCustomerScored(CustomerNo: Code[20]; Reason: Text; var Score: Integer)
begin
end;
}
codeunit 50522 "Existing Scoring Subscriber"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Customer Scoring Events", 'OnCustomerScored', '', false, false)]
local procedure OnCustomerScored(CustomerNo: Code[20]; var Score: Integer)
begin
if CustomerNo = '' then
Score := 0;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: events
keywords: [local-event, internal-event, event-subscriber, compatibility, access-modifier, integration-event, business-event, parameter-name, var-parameter, appsourcecop]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Treat local and internal events as subscriber contracts
## Description
The `local` and `internal` access modifiers on Business and Integration event publishers restrict who can raise the procedure; they do not prevent dependent extensions from subscribing. Once shipped, the event name and each existing parameter's name, type/subtype, and value-versus-`var` passing mode are compatibility contracts even when the publisher is not public. Parameter order is not a subscriber contract because subscribers bind the parameters they use by name. This differs from `[InternalEvent]`, which is module-only except for modules named by `internalsVisibleTo`.
## Best Practice
Preserve a shipped Business or Integration event's identity and every existing parameter's name, type/subtype, and passing mode regardless of the procedure access modifier. AS0025 protects names and types, while AS0063 and AS0077 protect removal and addition of `var`. New parameters may be added at any position on a `local` or `internal` event because subscribers can omit them; public event procedures follow the stricter caller contract described by `add-new-event-parameters-at-the-end`.
See sample: `treat-local-and-internal-events-as-subscriber-contracts.good.al`.
## Anti Pattern
Renaming or removing an existing parameter, changing its type/subtype, or adding/removing its `var` modifier because the event publisher procedure is `local` or `internal`. AppSourceCop checks these subscriber-breaking changes because dependent event subscribers can still bind to the event. Reordering unchanged parameters, or inserting a new parameter among them, is not this anti-pattern.
See sample: `treat-local-and-internal-events-as-subscriber-contracts.bad.al`.

View file

@ -0,0 +1,16 @@
// Demonstration-only AL. Version 1 shipped with only CalculateAmount().
interface "I Shipping Quote Bad"
{
procedure CalculateAmount(): Decimal;
// Added in version 2: every existing implementer now fails to compile.
procedure CalculateDeliveryDate(): Date;
}
codeunit 50511 "Existing Shipping Quote" implements "I Shipping Quote Bad"
{
procedure CalculateAmount(): Decimal
begin
exit(10);
end;
}

View file

@ -0,0 +1,23 @@
// Demonstration-only AL. Interface inheritance requires runtime 14.0 / BC25.
interface "I Shipping Quote"
{
procedure CalculateAmount(): Decimal;
}
interface "I Shipping Quote V2" extends "I Shipping Quote"
{
procedure CalculateDeliveryDate(): Date;
}
codeunit 50510 "Shipping Quote V2" implements "I Shipping Quote V2"
{
procedure CalculateAmount(): Decimal
begin
exit(10);
end;
procedure CalculateDeliveryDate(): Date
begin
exit(Today() + 1);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [16..]
domain: interfaces
keywords: [published-interface, interface-method, breaking-change, interface-extends, versioned-interface, appsourcecop, as0066]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Extend published interfaces; do not edit them
## Description
Adding a method to a shipped interface changes the contract every implementing codeunit must satisfy. Implementers can live in dependent extensions, so the addition breaks code the interface publisher cannot update; AppSourceCop reports AS0066. Interface inheritance is available from runtime 14.0 (Business Central 2024 release wave 2, BC25), but the original interface must remain unchanged.
## Best Practice
On BC25 or later, declare a new interface that `extends` the published interface and add the new method there. Existing implementers remain valid for the original contract, while new implementers opt in to the extended contract. For targets BC16 through BC24, where interface inheritance is unavailable, publish a new or versioned sibling interface instead.
See sample: `extend-published-interfaces-dont-edit-them.good.al`.
## Anti Pattern
Adding a procedure directly to an interface that has already shipped. Every dependent implementation must immediately add that procedure, so an otherwise compatible app update breaks its implementers.
See sample: `extend-published-interfaces-dont-edit-them.bad.al`.

View file

@ -0,0 +1,36 @@
// Demonstration-only AL. A removed enum-extension value left ordinal 700 in data.
enum 50503 "Delivery Method Bad" implements "I Delivery Method Bad"
{
Extensible = true;
DefaultImplementation = "I Delivery Method Bad" = "Default Delivery Method Bad";
value(0; Default)
{
}
}
interface "I Delivery Method Bad"
{
procedure Deliver();
}
codeunit 50504 "Default Delivery Method Bad" implements "I Delivery Method Bad"
{
procedure Deliver()
begin
end;
}
codeunit 50505 "Delivery Dispatch Bad"
{
procedure DeliverPersistedValue()
var
DeliveryMethod: Enum "Delivery Method Bad";
Delivery: Interface "I Delivery Method Bad";
begin
DeliveryMethod := 700;
// DefaultImplementation does not handle an ordinal that is not declared.
Delivery := DeliveryMethod;
Delivery.Deliver();
end;
}

View file

@ -0,0 +1,34 @@
// Demonstration-only AL. UnknownValueImplementation requires runtime 7.0 / BC18.
interface "I Delivery Method"
{
procedure Deliver();
}
codeunit 50500 "Unknown Delivery Method" implements "I Delivery Method"
{
procedure Deliver()
begin
Error(UnknownMethodErr);
end;
var
UnknownMethodErr: Label 'The saved delivery method is no longer installed. Select another method.';
}
codeunit 50501 "Default Delivery Method" implements "I Delivery Method"
{
procedure Deliver()
begin
end;
}
enum 50502 "Delivery Method" implements "I Delivery Method"
{
Extensible = true;
DefaultImplementation = "I Delivery Method" = "Default Delivery Method";
UnknownValueImplementation = "I Delivery Method" = "Unknown Delivery Method";
value(0; Default)
{
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [18..]
domain: interfaces
keywords: [unknownvalueimplementation, unknown-enum-value, persisted-ordinal, enum-extension, extension-uninstall, interface-fallback]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Handle unknown enum ordinals with UnknownValueImplementation
## Description
An enum ordinal can remain in persisted data after the enum extension that declared it is uninstalled. The ordinal is then unknown: it matches no currently declared enum value. `DefaultImplementation` does not cover this case; it covers declared values that have no explicit interface implementation. `UnknownValueImplementation`, available from runtime 7.0 (Business Central 2021 release wave 1, BC18), provides the distinct interface implementation for an unknown ordinal.
## Best Practice
On BC18 or later, set `UnknownValueImplementation = <Interface> = <Codeunit>;` on an enum that implements an interface and can be persisted. Use an implementation that reports a clear domain error or safely contains the unknown state. Keep `DefaultImplementation` separately when declared but unmapped values also need a fallback.
See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.good.al`.
## Anti Pattern
Defining only `DefaultImplementation` and assuming it also handles a stored ordinal whose enum value has disappeared. After an enum extension is uninstalled, converting that unknown ordinal to the interface can produce a technical runtime error instead of controlled handling.
See sample: `handle-unknown-enum-ordinals-with-unknownvalueimplementation.bad.al`.

View file

@ -11,11 +11,11 @@ application-area: [all]
## Description
An `enum` that `implements` an interface maps each value to a codeunit through the `Implementation` property. But an extensible enum can carry values that set no `Implementation` — values added later by an extension, or a value left intentionally blank. Assigning such a value to an interface variable and calling a method on it fails at runtime unless the enum provides a fallback. The enum-level `DefaultImplementation` property names the codeunit used whenever a value has no explicit `Implementation`, so resolution always yields a usable object. LLMs are generally unaware this property exists and leave the gap open.
An `enum` that `implements` an interface maps each declared value to a codeunit through the `Implementation` property. A declared value, including one supplied by an enum extension, can omit that mapping. Assigning that value to an interface variable then fails at runtime unless the enum provides `DefaultImplementation`. This property is for declared but unmapped values; an ordinal that is no longer declared is a different case covered by `handle-unknown-enum-ordinals-with-unknownvalueimplementation`.
## Best Practice
On any extensible enum that implements an interface, set `DefaultImplementation = <Interface> = <Codeunit>;` at the enum level, pointing at a safe implementation that does nothing harmful. Values with their own `Implementation` keep using it; declared values without one resolve to the default. For an ordinal that matches no currently declared value — for example persisted data left after an enum extension is uninstalled — runtime 7.0 and later can use `UnknownValueImplementation` as a distinct fallback. Do not recommend that property to apps targeting an earlier runtime.
On any extensible enum that implements an interface, set `DefaultImplementation = <Interface> = <Codeunit>;` at the enum level, pointing at a safe implementation. Values with their own `Implementation` keep using it; declared values without one resolve to the default. Do not rely on this property for persisted ordinals that match no declared enum value.
See sample: `set-defaultimplementation-on-enum.good.al`.