9.3 Extensibility Patterns & Non-Breaking Changes
Key Takeaways
- In enterprise multi-model environments, modifying existing public or protected method signatures or deleting schema objects constitutes a breaking change that causes downstream compilation failures.
- Chain of Command wraps only public and protected methods, and [Hookable(false)] closes a method to both Chain of Command and pre/post event handlers while [Hookable(true)] re-opens pre/post handlers only and never grants Chain of Command.
- To extend method capabilities without breaking downstream callers, developers must append optional parameters with default values or employ the Parameter Object Pattern.
- The [SysObsoleteAttribute] attribute enables managed API deprecation by providing descriptive migration instructions and specifying whether obsolete references trigger a compiler warning or a hard compile error.
- [Wrappable(true)] is the documented override that makes a final public or protected method wrappable by Chain of Command, and [Replaceable] is the separate attribute that lets an extender skip the mandatory next call.
9.3 Extensibility Patterns & Non-Breaking Changes
Quick Answer: Authoring enterprise-grade solutions in Dynamics 365 requires building non-breaking extensions. When multiple independent ISV packages and partner customizations depend on shared base models, altering existing method signatures, removing fields, or changing data types causes fatal compile-time failures across the application suite. Extensibility is governed by the
[Hookable(true/false)]and[Wrappable(true/false)]attributes. By default,publicandprotectedmethods are hookable, whileprivatemethods are not. To expand method functionality safely without breaking existing callers, developers append optional parameters with default values or adopt the Parameter Object Pattern. Deprecated code paths must be phased out gracefully using[SysObsoleteAttribute]with informative migration instructions.
1. Principles of Extensible Solution Design in D365 F&O
In Dynamics 365 Finance and Operations, application code is organized into independent Models compiled into separate Packages (.NET assemblies). When Model B references Model A, any breaking change in Model A's public API surface halts compilation of Model B.
Package Dependency Architecture
┌────────────────────────────────────────────────────────┐
│ Application Platform / Application Foundation (Base) │
└───────────────────────────────────┬────────────────────┘
│ Referenced by
┌───────────────────────────────────▼────────────────────┐
│ Application Suite (Microsoft Core ERP) │
└───────────────────────────────────┬────────────────────┘
│ Referenced by
┌───────────────────────────────────▼────────────────────┐
│ ISV Solutions (Independent Software Vendors) │
└───────────────────────────────────┬────────────────────┘
│ Referenced by
┌───────────────────────────────────▼────────────────────┐
│ Customer / Partner Customization Model │
└────────────────────────────────────────────────────────┘
What Constitutes a Breaking Change?
Any modification that forces dependent models to change their source code or fail compilation is a breaking change:
- Changing the name of a public or protected class, method, table, field, or Base Enum element.
- Adding a mandatory (non-optional) parameter to an existing public/protected method.
- Changing the parameter types or return type of an existing method.
- Changing the access modifier of a method from
publictoprotectedorprivate. - Deleting an existing table index or changing a non-unique index to unique.
- Reducing the string length of an Extended Data Type (EDT).
- Changing a Base Enum from
IsExtensible = TruetoIsExtensible = False.
2. The [Hookable] and [Wrappable] Attributes
Microsoft provides metadata attributes to explicitly define whether methods can be intercepted by external extensions.
Default Extensibility Rules
publicmethods: Hookable and Wrappable by default.protectedmethods: Hookable and Wrappable by default.privatemethods: Non-hookable and non-wrappable by default.
Explicit Attribute Overrides
// Explicitly preventing extensions from wrapping or hooking a sensitive financial algorithm
[Hookable(false)]
public AmountMST calculateTaxAtomic(TaxGroup _taxGroup, AmountMST _amount)
{
// Core calculation logic that must remain untouched
return _amount * 0.15;
}
| Attribute | Target Modifiers | Effect on Chain of Command (CoC) | Effect on Pre/Post Event Handlers |
|---|---|---|---|
| Default (No Attribute) | public, protected | Allowed | Allowed |
| Default (No Attribute) | private | Blocked (Compile error) | Blocked |
[Hookable(false)] | public, protected | Blocked (Compile error) | Blocked (Compile error) |
[Hookable(true)] | private | Blocked (CoC still requires public/protected) | Allowed (Event handlers can subscribe) |
[Wrappable(false)] | public, protected | Blocked (CoC cannot wrap) | Allowed (Event handlers still function) |
[Wrappable(true)] | final public, final protected | Allowed — the documented override that re-enables wrapping on a final method | Allowed |
[Wrappable(true)]: Opting a final Method Back In
By default a final public or protected method cannot be wrapped. The method's author can reverse that with [Wrappable(true)], and can equally remove wrappability from an ordinary non-final method with [Wrappable(false)]:
class AnyClass2
{
// Public, yet deliberately closed to Chain of Command.
[Wrappable(false)]
public void doSomething(str message) {...}
// Final, yet deliberately opened to Chain of Command.
[Wrappable(true)]
final public void doSomethingElse(str message) {...}
}
Keep the three attributes distinct, because the exam mixes them in a single option list:
| Attribute | Question It Answers |
|---|---|
[Hookable] | May this method be intercepted at all? [Hookable(false)] closes both CoC and pre/post handlers. [Hookable(true)] only re-opens pre/post handlers — it never grants CoC. |
[Wrappable] | May Chain of Command wrap this specific method, overriding the default that follows from final? |
[Replaceable] | May an extender that is wrapping this method skip the next call and substitute its own result? |
[!WARNING] Critical Exam Rule:
[Hookable(true)]on a Private Method Does NOT Enable CoC A favorite MB-500 exam question asks whether adding[Hookable(true)]to aprivatemethod allows it to be wrapped via Chain of Command. It does not. Chain of Command strictly requires the target method to be declared aspublicorprotected. Applying[Hookable(true)]to aprivatemethod only permits pre/post event handler subscriptions, never CoC wrapping.
3. Designing Non-Breaking Changes and API Evolution
When requirements evolve, developers must expand existing APIs without breaking external callers.
Strategy 1: Optional Parameters with Default Values
If a method must accept new input, append the new parameter at the end of the parameter list and assign it a compile-time default constant value:
// ORIGINAL API (Version 1.0)
public void postTransaction(JournalId _journalId, TransDate _transDate)
{
this.postTransactionInternal(_journalId, _transDate, false);
}
// NON-BREAKING API EXTENSION (Version 2.0)
// Appending optional parameter with default value preserves compatibility for all existing callers
public void postTransaction(JournalId _journalId, TransDate _transDate, boolean _autoApprove = false)
{
this.postTransactionInternal(_journalId, _transDate, _autoApprove);
}
Existing callers passing two arguments continue to compile and run seamlessly, while new callers can pass three arguments.
Strategy 2: The Parameter Object Pattern
For complex enterprise APIs subject to frequent enhancements, passing primitive parameters creates rigid method signatures. The Parameter Object Pattern encapsulates all arguments within a dedicated parameter class:
// Dedicated parameter container
public class TaxCalculationContract
{
public ItemId ItemId;
public Qty Quantity;
public CustAccount CustAccount;
// Future fields can be added here without changing method signatures!
public AddressCountryRegionId DestinationCountry;
}
// Stable method signature
public class TaxCalculationEngine
{
public TaxResult calculate(TaxCalculationContract _contract)
{
// Business logic consumes properties from _contract
}
}
Adding a new property to TaxCalculationContract never breaks existing callers of calculate().
4. API Deprecation Management with [SysObsoleteAttribute]
When an API, class, or data structure is superseded, it should not be deleted immediately. Deleting an API causes catastrophic compile breaks for all consuming packages. Instead, developers mark the artifact with [SysObsoleteAttribute] to execute a graceful deprecation lifecycle.
Syntax and Configuration
[SysObsoleteAttribute("calculateDiscount() is obsolete. Use SalesDiscountEngine::calculateDiscountV2() instead.", false)]
public AmountMST calculateDiscount(ItemId _itemId, Qty _qty)
{
return SalesDiscountEngine::calculateDiscountV2(_itemId, _qty, '');
}
The isError Parameter Mechanics
isError = false(Compiler Warning):- Emits a compilation Warning in Visual Studio.
- Existing code continues to compile and execute normally.
- Alerts developers that the method is deprecated and will be removed in a future release.
isError = true(Compiler Error):- Emits a compilation Fatal Error in Visual Studio.
- Prevents any downstream code from compiling against the obsolete method.
- Used after a deprecation grace period has elapsed.
5. Designing Granular Extension Points
Monolithic methods spanning hundreds of lines are impossible to extend cleanly via Chain of Command because wrapping the entire method forces wrappers to execute around the entire monolithic block.
Refactoring Monoliths into Hookable Extension Points
To author an extensible system, refactor large processes into smaller, single-purpose protected methods that act as discrete extension hooks:
public class SalesOrderProcessor
{
public void process(SalesTable _salesTable)
{
this.validateOrder(_salesTable);
this.reserveInventory(_salesTable);
this.postFinancials(_salesTable);
this.notifyCustomer(_salesTable);
}
// Granular extension points accessible via Chain of Command
protected void validateOrder(SalesTable _salesTable)
{
// Standard validation logic
}
protected void reserveInventory(SalesTable _salesTable)
{
// Standard reservation logic
}
protected void postFinancials(SalesTable _salesTable)
{
// Standard posting logic
}
protected void notifyCustomer(SalesTable _salesTable)
{
// Standard notification logic
}
}
An ISV or customer extension can now wrap reserveInventory() using CoC without touching validateOrder() or postFinancials().
6. Scenario Walk-Through: ISV Shipping Rate Provider Evolution
Scenario Description
An ISV develops a shipping logistics model used by 40 enterprise customers. The core shipping engine class ABC_CarrierEngine contains a method public ShippingRate getRate(Weight _weight, ZipCode _zip). In release 2.0, the engine needs to incorporate a package dimensions argument (Volume _volume) while ensuring none of the 40 existing customer models break during their next build.
Architectural Evolution Steps
- Maintain Original Method Signature: Retain the original method name and parameters.
- Append Optional Parameter with Default Value:
public ShippingRate getRate(Weight _weight, ZipCode _zip, Volume _volume = 0.0) { if (_volume > 0.0) { return this.calculateVolumetricRate(_weight, _zip, _volume); } return this.calculateStandardRate(_weight, _zip); } - Granular Protected Extension Points: Ensure
calculateStandardRateandcalculateVolumetricRateare markedprotectedso customers can wrap them via CoC. - Deprecate Legacy Alternate Methods: If an older helper
getRateByZoneis being retired, mark it with[SysObsoleteAttribute("Use getRate instead.", false)].
7. Real-World Exam Traps: Extensibility & Non-Breaking Changes
[!WARNING] Exam Trap 1: Assuming
[Hookable(false)]Methods Can Be Wrapped in CoC If a base Microsoft or ISV class method is decorated with[Hookable(false)], any attempt to wrap it using Chain of Command in an extension class fails compilation with error "The method cannot be wrapped because it is marked with Hookable(false)". The developer must seek an alternative extension point or submit an extensibility request.
[!WARNING] Exam Trap 2: Adding Mandatory Parameters to Public Methods When an exam scenario asks how to add a required audit context to a public service method without breaking third-party integrations, options that simply alter the method signature (e.g.,
myMethod(int _x, AuditContext _ctx)) are traps. Adding non-optional parameters breaks every existing caller. The correct solution is adding optional default parameters or overloading via helper methods.
[!WARNING] Exam Trap 3: Believing
[SysObsoleteAttribute]Automatically Deletes Code Applying[SysObsoleteAttribute]does not delete or disable the method at runtime. WhenisError = false, it merely instructs the compiler to emit a warning. Downstream code continues to execute normally unlessisErroris set totrue.
An ISV package defines a public class containing a sensitive currency calculation method: public AmountMST calculateExchangeAtomic(AmountMST _amount, CurrencyCode _currency). The ISV architect wants to strictly guarantee that no partner or customer extension can wrap this method using Chain of Command or attach pre/post event handlers to it. Which attribute must decorate the method?
A development team needs to deprecate an obsolete calculation method public Amount calculateTaxLegacy() in a core shared model. The team wants to allow existing downstream customer projects to continue compiling for the next six months, but the compiler must display a visible warning explaining that the method is obsolete and directing developers to use calculateTaxV2(). How should the method be decorated?
A developer needs to enhance a widely used public method public void validateOrder(SalesTable _salesTable) in a base model to accept an additional parameter boolean _checkCreditLimit. Thousands of callers across multiple models invoke this method passing only the _salesTable parameter. How can the developer make this change without breaking existing callers?
A developer decorates a private method on a standard class with [Hookable(true)] and attempts to wrap it using Chain of Command in an extension class. What is the result when compiling the extension model?