It was a Tuesday after a platform update, and a test user sent me a screenshot: sales lines were being saved without the custom discount code our extension always filled in. No error. No infolog. The build was green, the deployable package had gone through the pipeline cleanly, and my Chain of Command wrapper was still there in the model, compiling perfectly. It just was not running anymore.
At the end of my last article I admitted I had a form extension I was suddenly not proud of. Opening it up properly sent me back to the fundamentals of how Chain of Command really works, and I want to share what I relearned, because almost every extension bug I have chased in F&O comes down to one of a handful of misunderstandings about next.
WHAT NEXT REALLY DOES
Chain of Command lets you wrap a public or protected method of a class, table, form, form data source or form control without touching the original code. You create a final class decorated with ExtensionOf, declare a method with the same signature, and call next somewhere inside it. Everything before next runs before the standard logic; everything after it runs once the standard logic has returned.
[ExtensionOf(tableStr(SalesLine))]
final class SalesLine_DHDiscount_Extension
{
public void insert(boolean _dropInvent, boolean _findMarkup)
{
this.DHDiscountCode = this.dhDefaultDiscountCode();
next insert(_dropInvent, _findMarkup);
this.dhLogDiscountApplied();
}
}
Two details here bite people early. First, the wrapper repeats the signature exactly (copy it from the base method; the snippets in this article are trimmed to show the pattern) but never the default values: if the base method says boolean _dropInvent = false, your wrapper declares just boolean _dropInvent. Second, this inside the extension is the real record or object, so you can read and set its fields and call its public and protected members as if you were inside the original class.
THE CHAIN IS A STACK, NOT A QUEUE
Here is the mental model I wish I had been given on day one. Every model that wraps the same method adds one more layer around the base. When the caller invokes the method, the runtime walks inward through each wrapper's pre-code, reaches the base, and then walks back out through the post-code in reverse.
The part that surprises people is the order. If my model and an ISV model both wrap SalesLine.insert, there is no guarantee which wrapper sits closer to the base. It can differ between environments and between builds. So the rule I now follow without exception: my wrapper must produce the right result whether it runs first or last. If my logic needs a value the ISV sets, I read it after next has returned, never before.
THE WRONG WAY TO MAKE NEXT OPTIONAL
Sooner or later every developer wants to skip the standard logic in some situation. The compiler pushes back: next has to be called, and it cannot sit inside an if or a loop. So people get creative, and the creative versions are where the bugs live.
// Wrong: trying to dodge the base logic
public boolean validateWrite()
{
if (this.DHSkipStandard)
{
return true; // will not compile: next can't follow a return
}
return next validateWrite();
}
// Right: let the base run, add your rule after it
public boolean validateWrite()
{
boolean ok = next validateWrite();
if (ok && this.DHDiscountCode && !this.dhDiscountAllowed())
{
ok = checkFailed("Discount code is not allowed for this customer.");
}
return ok;
}
The right version does something subtle: it respects the return value. When a method returns a value, capture what next gives back, adjust it if you must, and return it. Throwing it away and returning your own true is the quickest way to make standard validation disappear without anyone noticing.
READ THE ATTRIBUTE BEFORE YOU WRITE A LINE
Not every method is a fair target. Microsoft marks methods with attributes that decide what you are allowed to do, and the attribute on the base method should drive your design rather than the other way round.
• A normal public or protected method: wrap it, call next unconditionally, put your logic around it.
• A method marked [Replaceable]: Microsoft explicitly allows you to skip next. Do it only when you really are replacing the behaviour, and write a comment explaining why, because the next developer will assume you forgot.
• Private methods, methods marked [Hookable(false)], and final methods without [Wrappable(true)]: you cannot wrap them. Look one level up for a public method that calls them, or for a delegate or event the class raises.
When I cannot find a seam, I raise an extensibility request with Microsoft rather than copying standard code into my model. Copied code compiles today and silently diverges from the platform with every update.
FORMS AND DATA SOURCES: SAME RULES, DIFFERENT TARGETS
Forms are where most of my extensions live, and the syntax changes slightly depending on what you wrap. The whole form uses formStr, a data source uses formDataSourceStr, a field on a data source uses formDataFieldStr, and a control uses formControlStr.
[ExtensionOf(formDataSourceStr(SalesTable, SalesLine))]
final class SalesTable_SalesLineDS_DHDiscount_Extension
{
public int active()
{
int ret = next active();
SalesLine line = this.cursor();
this.object(fieldNum(SalesLine, DHDiscountCode))
.allowEdit(line.SalesStatus == SalesStatus::Backorder);
return ret;
}
}
Inside a data source extension, this is the data source, not the record, which is why the code calls cursor(). Mixing those two up is the most common compile error I see in reviews. And because active fires every time the user moves between lines, anything you put there must be cheap: no queries, no service calls.
WHAT GOES WRONG
Back to my Tuesday. The wrapper compiled and simply never fired, because the update had moved the logic I depended on into a different method. My extension was wrapping a method that the standard flow no longer called on that path. Nothing warned me. These are the failures I now check for first:
• The silent bypass. The base code is refactored and your wrapped method stops being on the execution path. Diagnose with the trace parser: record the scenario, search for your extension class, and if it is missing from the call tree you know where to look.
• Order dependency. Your logic works in one environment and fails in another because another model's wrapper runs in a different position. The fix is design, not configuration: read shared state only after next.
• Swallowed return values. A validateWrite or canSubmit that returns its own value instead of the one from next. Search your models for wrappers that never use the result of next.
• Heavy pre-code inside a transaction. A query in the pre-code of insert or update runs inside the caller's ttsbegin block. Under load that means longer locks, update conflicts, and occasionally deadlocks. Move the work after next, or out of the transaction altogether.
• Recursion by accident. Calling this.insert() instead of next insert() inside the wrapper. It compiles, and then it runs until the stack gives up.
The habit that would have caught my bug before the test user did is a small SysTest per important wrapper. Not a full test suite, just one test that inserts a record and asserts that the field my extension sets is actually set. When an update moves the logic, the test fails in the pipeline instead of in front of a user.
MY CHECKLIST BEFORE I MERGE A WRAPPER
1. I checked the attribute on the base method and my design matches it.
2. next is called once, unconditionally, and its return value flows into mine.
3. My logic gives the right result whether my wrapper runs first or last.
4. Nothing expensive runs inside a transaction or an event that fires per line.
5. There is one SysTest that proves the wrapper still runs after the next update.
Five lines, ten minutes per extension. It is the cheapest insurance I know of in F&O development, and it turned my Tuesday screenshot into the last time that particular bug reached a user.
Next time I will look at what happens when two extensions need to share state: why static variables in extension classes are a trap, and how SysGlobalObjectCache and proper parameter passing keep your wrappers honest.
It was a Tuesday after a platform update, and a test user sent me a screenshot: sales lines were being saved without the custom discount code our extension always filled in. No error. No infolog. The build was green, the deployable package had gone through the pipeline cleanly, and my Chain of Command wrapper was still there in the model, compiling perfectly. It just was not running anymore.
At the end of my last article I admitted I had a form extension I was suddenly not proud of. Opening it up properly sent me back to the fundamentals of how Chain of Command really works, and I want to share what I relearned, because almost every extension bug I have chased in F&O comes down to one of a handful of misunderstandings about next.
WHAT NEXT REALLY DOES
Chain of Command lets you wrap a public or protected method of a class, table, form, form data source or form control without touching the original code. You create a final class decorated with ExtensionOf, declare a method with the same signature, and call next somewhere inside it. Everything before next runs before the standard logic; everything after it runs once the standard logic has returned.
[ExtensionOf(tableStr(SalesLine))]
final class SalesLine_DHDiscount_Extension
{
public void insert(boolean _dropInvent, boolean _findMarkup)
{
this.DHDiscountCode = this.dhDefaultDiscountCode();
next insert(_dropInvent, _findMarkup);
this.dhLogDiscountApplied();
}
}
Two details here bite people early. First, the wrapper repeats the signature exactly (copy it from the base method; the snippets in this article are trimmed to show the pattern) but never the default values: if the base method says boolean _dropInvent = false, your wrapper declares just boolean _dropInvent. Second, this inside the extension is the real record or object, so you can read and set its fields and call its public and protected members as if you were inside the original class.
THE CHAIN IS A STACK, NOT A QUEUE
Here is the mental model I wish I had been given on day one. Every model that wraps the same method adds one more layer around the base. When the caller invokes the method, the runtime walks inward through each wrapper's pre-code, reaches the base, and then walks back out through the post-code in reverse.
The part that surprises people is the order. If my model and an ISV model both wrap SalesLine.insert, there is no guarantee which wrapper sits closer to the base. It can differ between environments and between builds. So the rule I now follow without exception: my wrapper must produce the right result whether it runs first or last. If my logic needs a value the ISV sets, I read it after next has returned, never before.
THE WRONG WAY TO MAKE NEXT OPTIONAL
Sooner or later every developer wants to skip the standard logic in some situation. The compiler pushes back: next has to be called, and it cannot sit inside an if or a loop. So people get creative, and the creative versions are where the bugs live.
// Wrong: trying to dodge the base logic
public boolean validateWrite()
{
if (this.DHSkipStandard)
{
return true; // will not compile: next can't follow a return
}
return next validateWrite();
}
// Right: let the base run, add your rule after it
public boolean validateWrite()
{
boolean ok = next validateWrite();
if (ok && this.DHDiscountCode && !this.dhDiscountAllowed())
{
ok = checkFailed("Discount code is not allowed for this customer.");
}
return ok;
}
The right version does something subtle: it respects the return value. When a method returns a value, capture what next gives back, adjust it if you must, and return it. Throwing it away and returning your own true is the quickest way to make standard validation disappear without anyone noticing.
READ THE ATTRIBUTE BEFORE YOU WRITE A LINE
Not every method is a fair target. Microsoft marks methods with attributes that decide what you are allowed to do, and the attribute on the base method should drive your design rather than the other way round.
• A normal public or protected method: wrap it, call next unconditionally, put your logic around it.
• A method marked [Replaceable]: Microsoft explicitly allows you to skip next. Do it only when you really are replacing the behaviour, and write a comment explaining why, because the next developer will assume you forgot.
• Private methods, methods marked [Hookable(false)], and final methods without [Wrappable(true)]: you cannot wrap them. Look one level up for a public method that calls them, or for a delegate or event the class raises.
When I cannot find a seam, I raise an extensibility request with Microsoft rather than copying standard code into my model. Copied code compiles today and silently diverges from the platform with every update.
FORMS AND DATA SOURCES: SAME RULES, DIFFERENT TARGETS
Forms are where most of my extensions live, and the syntax changes slightly depending on what you wrap. The whole form uses formStr, a data source uses formDataSourceStr, a field on a data source uses formDataFieldStr, and a control uses formControlStr.
[ExtensionOf(formDataSourceStr(SalesTable, SalesLine))]
final class SalesTable_SalesLineDS_DHDiscount_Extension
{
public int active()
{
int ret = next active();
SalesLine line = this.cursor();
this.object(fieldNum(SalesLine, DHDiscountCode))
.allowEdit(line.SalesStatus == SalesStatus::Backorder);
return ret;
}
}
Inside a data source extension, this is the data source, not the record, which is why the code calls cursor(). Mixing those two up is the most common compile error I see in reviews. And because active fires every time the user moves between lines, anything you put there must be cheap: no queries, no service calls.
WHAT GOES WRONG
Back to my Tuesday. The wrapper compiled and simply never fired, because the update had moved the logic I depended on into a different method. My extension was wrapping a method that the standard flow no longer called on that path. Nothing warned me. These are the failures I now check for first:
• The silent bypass. The base code is refactored and your wrapped method stops being on the execution path. Diagnose with the trace parser: record the scenario, search for your extension class, and if it is missing from the call tree you know where to look.
• Order dependency. Your logic works in one environment and fails in another because another model's wrapper runs in a different position. The fix is design, not configuration: read shared state only after next.
• Swallowed return values. A validateWrite or canSubmit that returns its own value instead of the one from next. Search your models for wrappers that never use the result of next.
• Heavy pre-code inside a transaction. A query in the pre-code of insert or update runs inside the caller's ttsbegin block. Under load that means longer locks, update conflicts, and occasionally deadlocks. Move the work after next, or out of the transaction altogether.
• Recursion by accident. Calling this.insert() instead of next insert() inside the wrapper. It compiles, and then it runs until the stack gives up.
The habit that would have caught my bug before the test user did is a small SysTest per important wrapper. Not a full test suite, just one test that inserts a record and asserts that the field my extension sets is actually set. When an update moves the logic, the test fails in the pipeline instead of in front of a user.
MY CHECKLIST BEFORE I MERGE A WRAPPER
1. I checked the attribute on the base method and my design matches it.
2. next is called once, unconditionally, and its return value flows into mine.
3. My logic gives the right result whether my wrapper runs first or last.
4. Nothing expensive runs inside a transaction or an event that fires per line.
5. There is one SysTest that proves the wrapper still runs after the next update.
Five lines, ten minutes per extension. It is the cheapest insurance I know of in F&O development, and it turned my Tuesday screenshot into the last time that particular bug reached a user.
Next time I will look at what happens when two extensions need to share state: why static variables in extension classes are a trap, and how SysGlobalObjectCache and proper parameter passing keep your wrappers honest.
No comments yet. Be the first to comment!