Extensibility - Fact Check
Introduction
Recently, I participated in a longer online conversation with a group of skilled Dynamics partners. The topic was extensibility, and we covered a lot of ground. To me the questions as well as the findings were eye-opening, and I want to share for the benefit of the entire community.
I'm daily working on the receiving-end of extensibility requests, and so far I've triaged +1000 extensibility request, and personally implemented +500. Yes, that's a lot of pull-requests and code reviews. Please consider me an ally in enabling you to deliver the best possible solutions on time for your customers. This article is an attempt to explain some of details of extensibility and their implications. Nothing new, really, a refresher for some, a more approachable way for other. Just so we together can build some amazing solutions for our customers.
Here we go - let's explore 10 extensibility theses.
1: Most newly added methods by Microsoft are internal
As an extender of Microsoft logic you have probably come across internal methods that you need to reference. To move forward you had to log an extensibility request, and be patient. This could lead to the impression that the majority of newly added methods are internal (and thus cannot be referenced in your extension).
Let's look at some numbers. Between 10.0.1 and 10.0.30 more public methods has been added (27k) than private (14k) and internal (10k) combined.
In total 10.0.30 has 3.27% internal methods.
Conclusion
If taken numerically, then this is obviously a myth. However, the number of internal methods are increasing, and you will see them more often. And if we included an annoyance factor for extenders, they surely would be in majority.
2: The coupling of access specifiers and extensibility is suboptimal
First let's understand method access specifiers. They control the scope in which a method is known. In other words you use access specifiers to filter where the compiler knows about your method. In object oriented design this is a big part of how encapsulation gets implemented. As X++ is a .NET language, the access specifiers behaves just like other .NET languages, like C#.
Here is a quick overview:
Now, let's look at a method's extensibility traits (hookable, wrappable and replaceable). They are explained here: Attributes that make methods extensible - Finance & Operations | Dynamics 365 | Microsoft Learn
Method's extensibility traits are defaulted based on the access specifiers. If you are not happy with the default, just override it by explicitly decorating the method with one or more of the attributes: Hookable, Wrappable or Replaceable.
That's it. The only coupling is defaulting. Is it optimal or not? A lot of consideration was placed into this defaulting. The design was chosen for several reasons:
It continues the X+ tradition of being "open by default."
- If you omit a method access specifier the method is public. (In C# they are private)
- If you omit a class access specifier the class is public. (In C# they are private)
- If you omit a class field access specifier the member is protected. (In C# they are private)
- Protected methods can by default be overridden. You can opt out by making the final. (In C# you need to opt-in using the virtual keyword)
Public and Protected methods are wrappable by default.
This was an easy decision. Public and protected methods were already accessible externally, and as such their signatures are immutable to maintain binary compatibility.
The alternative would be to retrofit extensibility attributes on all "the right places". It got no votes, and as far as I know everyone are pleased with this decision.
Private methods are not extensible.
Again an easy decision. As a private method is only known inside the file it is declared, it by nature cannot be extended.
Internal methods are not wrappable by default.
This one took a while to reach consensus on. We opted for keeping internal methods non-extensible by default (you can override if you want, or make it public), primarily because internal methods are typically not extended given their reduced scope.
No methods are replaceable by default.
Again an easy decision. Replacing a method can be quite intrusive, and can cause problems with servicing the solution. This should only be enabled with caution.
Conclusion
Inventing a new object orientation concept, and fitting it into an existing language is far from trivial. I cannot see any other way, that would give a better overall experience. Nor has anyone shared a better alternative.
To my knowledge there is just one minor oddity - it now make sense to have protected methods on final classes, and tables.
3: Hookable(false) on internal methods is non-sense
As we saw above, then internal methods are not-hookable (or wrappable) by default, so in essence there is no need to explicitly state it.
One could argue that it is better to be explicit, for example, for access specifiers that makes a lot of sense. Yet the opposite argument of removing unnecessary clutter from the code base is equally valuable.
The million dollar question here is: If it brings no value, why are there so many of them? The answer is that habits are hard to change. In earlier versions there was a bug in a corner case where internal methods could be wrapped, unless the method was explicitly decorated with hookable(false). This bug is long fixed, but the engineers writing X++ code are still doing it - probably copy/pasting a deprecated practice.
Conclusion
Decorating internal methods as hookable(false) has no runtime or design time impact.
4: Hookable(true) on internal methods is non-sense
This one is simple. Internal methods can be hooked, wrapped and even replaced. Of course, only within the scope where the internal method is known, i.e. within current and friendly assemblies.
Conclusion
Internal methods are by default not hookable. The way to override the default is to decorate it. This makes the method extensible within its scope. There are plenty of valid use cases for this.
5: It would be better if all methods were extensible.
From the extenders point of view many things certainly would be better. No need to log extensibility requests again, stay in the flow, deliver timely to customers.
So why are we not?
Firstly, there is the obvious and somewhat contentious argument about being able to innovate. Extensibility is serious. Once you make a method extensible you are promising to not change it. This makes refactoring, innovation and progress dreadfully slow, and sometimes impossible. For example, you might have come across the context-pattern - which is a way of passing extra parameters to a method without changing the method signature. The code is harder to read and understand, but it is the price to pay to workaround the concrete you pour when methods are made extensible.
Secondly, there is performance. The X++ compiler generates extra code to make methods hookable (raising events) and wrappable (invoking the chain of command). Extra code means slower execution. The following chart shows the impact. A private method is 14x times faster than a public method. It is worth noticing that making a wrappable method replaceable is not causing any additional runtime overhead. This makes sense, as Replaceable just disables a compile-time check validating if next is called unconditionally.
These overheads are small, and dwarfed by user interaction or SQL calls, yet, there is no need to enable things not needed. This is why you'll see parm methods being marked as hookable(false), and just like most of the Acceptance Test Library is - we want our tests to run as fast as possible.
Conclusion
The implications of just making everything extensible are just too dire. Today about 85% of methods are extensible, getting to 100% will have a significant cost.
6: Microsoft could determine the right extensibility points up-front
This is an intriguing proposition. If everything cannot be extensible, could at least "the right" parts be extensible? Suppose Microsoft carefully considered, even for a longer period, what partners would like to extend and then made that extensible be default. Don't put anything into the product unless it is fully matured with a covering extensibility story.
"Prediction is very difficult, especially if it is about the future." Niels Bohr
You will find internal methods, that are obvious extension points. Some that easily could be predicted. However, it might be hard to predict if anyone needs to extend the given area at all. Just like it is hard to predict if any changes are coming in the area in the future. This is why Microsoft engineers typically play it safe. By favoring internal or private in new areas, then no concrete has been poured. Let's await partner feedback (for example extensibility requests) before locking down the APIs.
The world of software development has come far since the waterfall days. In the Dynamics team we no longer do multi-year releases, we do monthly releases. We don't do large design documents up front, trying to hash out every single aspect. We learnt we typically got it wrong, if we managed to release at all. Instead we iterate much faster. We thrive on feedback. We have preview programs. We get trillions of telemetry events from actual usage. We use all these sources to understand what is working and what needs course correction.
Conclusion
As I'm reading extensibility requests, I rarely stumble across cases where I think the Microsoft engineer could have anticipated the request. Should have known. Been less risk averse. This speaks to that most new code is extensible by default and the partners are super creative. More often I come across extensibility requests that are rejected, and I praise the engineer who closed down the API - typically because there is a better way for the partner of achieving the goal.
7: Microsoft has access to partner source code
No, no and no. The list of reasons why source code from partners and customers are not available to Microsoft is long. Microsoft cannot see the source, period!
This means that Microsoft cannot recompile partner logic when new versions of standard code is released.
This means Microsoft engineers cannot peek into partner code to understand exactly how it might be impacted by changes we make. This doesn't mean that Microsoft is completely blindfolded. Model files contain cross-reference information, and when a model is deployed, Microsoft collects the references to Microsoft code. For example, VAR_Extension references Global::Error(). We use this to avoid breaking binary compatibility. (See below).
Conclusion
A quintessential myth.
8: Microsoft guarantees binary compatibility
Binary compatibility is a requirement for pushing updates. It means that new versions of an assemblies can replace previous versions using a simple file copy operation. Other existing assemblies referencing the upgraded assembly can still resolve all references, to methods, classes, tables, etc.
Without binary compatibility we would be back in the medieval-like times, where for every upgrade a partner would have to recompile their code, resolve any issues, and deploy. A costly affair, often causing customers to defer upgrading, not benefitting from latest innovations and risk compliance issues, seeing their ERP investment deprecate. A pull model, that doesn't belong in a cloud-first world.
So yes, Microsoft guarantees binary compatibility. To guarantee this we use the compatibility checker tool on every pull request. If an engineer inadvertently attempts to do something breaking binary compatibility, the tool will reject the change. A classical example is adding an optional parameter to a method to a non-final method. This is a breaking change, and thus is not permitted.
If you think about it, this is a really steep requirement, and it makes evolving the code base quite a hassle. Once an API is released, it is locked. It is like pouring concrete. If we want to change an API, we have a deprecation process. We mark the API as obsoleted, and wait a full year before considering to delete it. After a year we look at the collected external cross references. If any external dependency exist, we let the API stay for another period. Trusting partners will recompile at least every 12 months (per our recommendations), and clean up dependencies to obsoleted APIs. If you come across methods with a deprecation date in 2019, you now know why. Please help all of us, by cleaning your code periodically.
Conclusion
This is indeed true. So far, we've released 33 binary compatible versions, still counting.
9: Microsoft never breaks extensibility points
I so wish this was true. Unfortunately despite the best efforts by the most talented people I know, sometimes something breaks. This speaks to the complexity of the product and extension landscape. The next best thing is to be prepared, that we are.
Let me illustrate with an example.
Suppose we have a class like this in version n. It obviously has a bug.
public class Math { public real add(real _value1, real _value2) { return _value1 + _value2 + 1; } }
Suppose the Math class gets extended by a partner. The partner correctly reuses the add() method when implementing an average() method. Then the partner realizes the bug in Microsoft code, compensates for it, and thereby creating an unfortunate dependency on an implementation detail.
[ExtensionOf(classStr(Math))] public final class Math_Extension { public real average(real _value1, real _value2) { return (this.add(_value1 + _value2) - 1) / 2; } }
Now Microsoft discovers the bug and fixes it in version n+1. A typical fix would look like this:
public class Math { public real add(int _value1, int _value2) { if (MathAdditionFlight::instance().isEnabled()) { return _value1 + _value2; } return _value1 + _value2 + 1; } }
Once this hits a customer with the above extension, then the average() method is broken. Did Microsoft know that an extension was compensating for the bug: No. Did Microsoft fix an obvious bug that should benefit everyone: Yes. Yet, something broke. While this example is contrived, that's because of the simplicity of the example. Real life contains plenty of such examples where subtle changes in behavior have down stream ripple effects.
When things break, what matters is how you react. Ideally the situation should be correctable instantly. That is where the flight comes in. Microsoft can restore the old behavior by disabling the flight (aka a kill-switch) on a given environment. This doesn't require any downtime, and typically take effect within an hour.
This rarely happens, but we are prepared. This example explains one of the three reasons extensions break based on my experience. The other two are:
- When Microsoft stops or starts calling a method. Any change to the product changes a behavior (that's what motivates the change in the first place.) Sometimes a different code path is used. Typically we go far to keep firing existing methods, but sometimes that makes no sense. If an extension depends on a particular code path there is a risk it will no longer fire and thus break. When extensions stay congruent with the methods they wrap, then the likelihood is much reduced. An example of a non-congruent extension could be to assume when a parameter::find() method is called that signals a certain business event.
- Extensions that bypass constraints. Recently I spent a Saturday helping a customer understand why an extension report had stopped working after an upgrade. System was complaining about a missing parameter. The odd part was, that this parameter was newly added to a completely different report. Turns out that the extension report was a copy of the other report, and instead of asking for an extension point to substitute one report with the other, the partner had found an open spot in SSRS where the new report name could be injected deep in the core of the reporting engine. After upgrade the report execution started with one report with 7 parameters, and suddenly it had to render a report with 6 parameters. Nothing I could do to help, except ask them to call their partner.
Conclusion
Most customers or partners will never experience extension points breaking, but it can happen. By creating robust extensions, you can significantly minimize the risk.
10: X++ is the most extensible language on the planet
Let's end on a lighter note, and Bing this question.
Conclusion
I'm not going to argue with Bing. Happy extending!
Technical expert Dynamics 365 for Finance and Operations
1 年Thanks for this very interesting article. Concerning the binary runtime compatibility, there is one case where compatibility checker might not be fully waterproof, that is where a dll is also used by a partner, and the dll gets upgraded by MS. Imagine the case where an ISV is calling methods that are in Microsoft.WindowsAzure.Storage.dll (this dll is available in the root bin directory, so we don't even need to reference it with AxReference). Microsoft puts a newer version of this dll in place, but some method signatures have changed or some methods were even removed from that dll. If I am not mistaken, this could cause a runtime issue. (This is a real live example where the Microsoft.WindowsAzure.Storage.dll was upgraded from version 4.3.0.0 to 9.3.2.0 in PU58 and e.g. Microsoft.WindowsAzure.Storage.Blob.CloudBlockBlob.UploadFromFileAsync was no longer available in the higher version). I know it is kind of a corner case, but it is a real live example for us. Luckily we compile all of our code with each PU that is announced.
Manager Product Development at DXC Technology
1 年Thanks Michael, in regards to your statements around bullet 1. The numbers might say that a lot of public and protected methods have been added but how many of them have been added with the attribute [Hookable(false)]? I would imagine that would be most of them as all new development is being locked down for extensions. Don't get me wrong I understand it from a MS perspective, it makes it easier for you to refactor code and introduce new features without making breaking changes. It's just been frustrating from a partner perspective when the time to get an extension request through is not always as fast as a project requires. If we could ask for one change it would be to reconsider your approach to apply [Hookable(false)] to base methods that are public. It's frustrating when you come across for example a table init() method that has been marked as [Hookable(false)] because there is some MS code in it. See for example any model added lately. It would make more sense if your practise would be to move the MS code in an table base method to a separate method and make it internal than making the init method [Hookable(false)] which seem to be the standard practise now. Keep up the great effort implementing extension requests!
Senior Developer bei Arineo GmbH
1 年Nice article, Michael. Just out of curiosity, did you count the InternalUseOnly attribute with the internal access specifier?