·8 min read·Rishi

Chain of Responsibility in D365 F&O: Extending Standard Logic Without Overlayering

Chain of Responsibility in D365 F&O: Extending Standard Logic Without Overlayering

Overlayering is dead. If you are still modifying standard X++ classes directly in D365 Finance & Operations, you are creating upgrade debt that compounds with every platform update. Chain of Responsibility (CoR) is the extension model that replaced it — and understanding how it works is non-negotiable for any F&O developer working on customizations today.

This guide covers the mechanics, the patterns that work, and the mistakes that will cost you hours in debugging.

What Chain of Responsibility Actually Is

CoR lets you wrap methods on standard classes without modifying the original code. Your extension class intercepts the method call, runs your pre-logic, calls next to execute the original (or the next extension in the chain), and then runs your post-logic.

The runtime builds a chain: your extension wraps the standard method. If another ISV or extension also wraps the same method, they form a chain — each calling next to pass control to the next link. The original method is always the last link.

Your Extension → ISV Extension → Standard Method
     ↓ next          ↓ next           (executes)

This means multiple extensions can wrap the same method without conflicting — as long as each calls next.

The Basics: Your First CoR Extension

Here is the minimal structure. We are extending SalesLineType to add validation before a sales line is inserted.

[ExtensionOf(classStr(SalesLineType))]
final class SalesLineType_Extension
{
    public void validateWrite(boolean _skipCreditCheck)
    {
        SalesLine salesLine = this.salesLine();

        // Pre-logic: custom validation before the standard insert
        if (salesLine.SalesQty > 1000
            && !salesLine.OverrideHighQtyApproved)
        {
            throw error("Quantities over 1000 require manager approval.");
        }

        // Call the next link in the chain (standard method or another extension)
        next validateWrite(_skipCreditCheck);

        // Post-logic: runs after the standard method completes
        Info(strFmt("Sales line %1 validated successfully.", salesLine.ItemId));
    }
}

Rules You Cannot Break

  1. The class must be final — extension classes are always declared final
  2. Use ExtensionOf attribute[ExtensionOf(classStr(TargetClass))] links your extension to the target
  3. You must call next — if you skip the next call, you break the chain. The standard method never executes. Other extensions in the chain never execute. This is the single most common CoR bug
  4. Method signature must match exactly — same name, same parameters, same return type. A mismatch means your extension silently does nothing
  5. No constructor extensions — you cannot wrap new(). Use event handlers or onConstructing delegates instead

Wrapping Methods with Return Values

When the method returns a value, you call next and capture the return:

[ExtensionOf(classStr(PurchFormLetterInvoice))]
final class PurchFormLetterInvoice_Extension
{
    protected boolean checkInvoicePolicies(
        VendInvoiceInfoTable _vendInvoiceInfoTable)
    {
        boolean result = next checkInvoicePolicies(_vendInvoiceInfoTable);

        // Post-logic: add a custom policy check after standard checks pass
        if (result)
        {
            result = this.checkCustomCompliancePolicy(
                _vendInvoiceInfoTable);
        }

        return result;
    }

    private boolean checkCustomCompliancePolicy(
        VendInvoiceInfoTable _vendInvoiceInfoTable)
    {
        // Your custom compliance validation
        if (_vendInvoiceInfoTable.InvoiceAmount > 50000
            && !ComplianceApproval::exists(
                _vendInvoiceInfoTable.PurchId))
        {
            warning("Invoices over 50,000 require compliance sign-off.");
            return false;
        }

        return true;
    }
}

Notice the pattern: call next first, capture the result, then layer your logic on top. You are augmenting the standard behavior, not replacing it.

Accessing Protected Members

CoR extensions can access protected methods and variables on the base class — you call them with this. You cannot access private members.

[ExtensionOf(classStr(LedgerJournalCheckPost))]
final class LedgerJournalCheckPost_Extension
{
    public void postJournal()
    {
        // Access a protected method on the standard class
        LedgerJournalTable journalTable = this.getLedgerJournalTable();

        // Pre-logic: log the journal before posting
        CustomAuditLog::logJournalPostAttempt(
            journalTable.JournalNum,
            curUserId());

        next postJournal();

        // Post-logic: log success
        CustomAuditLog::logJournalPostComplete(
            journalTable.JournalNum,
            curUserId());
    }
}

If you need access to a private member and there is no protected accessor, you have two options:

  • Use a pre/post event handler instead of CoR (event handlers receive the class instance but still cannot access private members — though they can access the public API)
  • Open a support request or extensibility request with Microsoft to expose the member

Do not use reflection hacks to access private members. They break on platform updates and violate the extension model contract.

Extending Table Methods

CoR works on table methods too. The attribute changes slightly:

[ExtensionOf(tableStr(SalesTable))]
final class SalesTable_Extension
{
    public void initFromCustTable(CustTable _custTable)
    {
        next initFromCustTable(_custTable);

        // After standard initialization, set custom fields
        this.CustomDeliveryPriority =
            CustomDeliverySettings::getPriority(_custTable.AccountNum);
        this.CustomRegion =
            CustomRegionMapping::getRegion(_custTable.PostalAddress);
    }
}

For insert(), update(), and delete() methods on tables:

[ExtensionOf(tableStr(PurchTable))]
final class PurchTable_Extension
{
    public void insert()
    {
        // Pre-insert: generate a custom tracking number
        if (!this.CustomTrackingId)
        {
            this.CustomTrackingId =
                NumberSeq::newGetNum(
                    CustomParameters::numRefTrackingId()).num();
        }

        next insert();

        // Post-insert: send notification
        CustomNotification::purchOrderCreated(this.PurchId);
    }
}

Extending Form Methods and Data Sources

Forms follow the same pattern but target the form class:

[ExtensionOf(formStr(SalesTable))]
final class SalesTable_Form_Extension
{
    public void init()
    {
        next init();

        // After form initialization, customize UI
        FormControl customButton =
            this.design().controlByName('CustomApprovalButton');

        if (customButton)
        {
            customButton.visible(
                CustomApprovalHelper::userCanApprove(curUserId()));
        }
    }
}

For form data source methods:

[ExtensionOf(formDataSourceStr(SalesTable, SalesTable))]
final class SalesTableDS_Extension
{
    public void init()
    {
        next init();

        // Add a custom range to the data source query
        QueryBuildDataSource qbds = this.query().dataSourceTable(
            tableNum(SalesTable));
        qbds.addRange(fieldNum(SalesTable, SalesStatus))
            .value(queryValue(SalesStatus::Backorder));
    }

    public boolean validateWrite()
    {
        boolean result = next validateWrite();

        SalesTable salesTable = this.cursor();

        if (result && salesTable.CustomCreditHold)
        {
            result = checkFailed("Order is on credit hold.");
        }

        return result;
    }
}

CoR vs. Event Handlers: When to Use Which

Both CoR and event handlers let you extend standard code without overlayering. The decision framework:

ScenarioUse CoRUse Event Handler
Wrap a method with pre/post logicYesPossible but CoR is cleaner
Conditionally skip standard logicYes (guard before next)No — event handlers cannot prevent execution
React to an event without modifying flowOverkillYes — onInserting, onValidatedWrite, etc.
Multiple ISVs extending the same methodYes — chain handles orderingYes — but no guaranteed execution order
Extend new() / constructorsNo — not supportedYes — via delegates or onConstructing
Need access to method-local variablesNoNo (neither can)

Rule of thumb: if you need to modify behavior, use CoR. If you need to react to behavior, use event handlers.

Common Mistakes and How to Avoid Them

Forgetting next

The most dangerous mistake. Your extension silently swallows the standard logic. Everything appears to work in your test scenario, but the standard method never executes.

Always search your extension class for next before committing. Every wrapped method must have exactly one next call.

Calling next Conditionally

// DANGEROUS — think twice before doing this
public void post()
{
    if (this.shouldSkipPosting())
    {
        return; // next is never called — standard posting is skipped
    }

    next post();
}

This is sometimes intentional — you genuinely want to prevent the standard method from running. But be aware: you are also preventing every other extension in the chain from running. If an ISV solution has its own CoR extension on the same method, your skip logic breaks their extension too.

If you must conditionally skip, document it clearly and test with all other extensions in the chain.

Wrong Method Signature

// BROKEN — extra parameter that doesn't exist on the standard method
public void validateWrite(boolean _skipCreditCheck, boolean _myFlag)
{
    next validateWrite(_skipCreditCheck, _myFlag);
}

The compiler will not always catch this. The extension simply does not bind — your code never executes, with no error or warning. Always verify the exact signature of the method you are wrapping by reading the standard class source.

Extension classes do not have their own state (instance variables) that persists between method calls. The extension shares state with the base class instance. If you need to pass information from your pre-logic to your post-logic, use SysExtensionSerializerMap or a similar pattern:

[ExtensionOf(classStr(PurchFormLetterInvoice))]
final class PurchFormLetterInvoice_Extension
{
    public void post()
    {
        // Store pre-post state
        boolean wasOnHold = this.purchTable().PurchStatus
            == PurchStatus::Hold;

        next post();

        // Use stored state in post-logic
        if (wasOnHold)
        {
            CustomAuditLog::logHoldReleasePosting(
                this.purchTable().PurchId);
        }
    }
}

Use local variables for simple cases. For complex state, use SysExtensionSerializerMap with a unique key.

Debugging CoR Extensions

When a CoR chain behaves unexpectedly:

  1. Set breakpoints in your extension — verify your code is actually executing. If it is not, the method signature is wrong
  2. Check the chain order — in the debugger, step into next to see what executes. The chain order is determined by load order, which is not guaranteed across models
  3. Search for other extensions — run a cross-reference search for ExtensionOf(classStr(YourTargetClass)) across all models. Another extension might be interfering
  4. Verify next is called — if standard behavior is missing, an extension in the chain is skipping next

Key Takeaway

Chain of Responsibility is the only supported way to extend standard X++ class behavior in D365 F&O. The model is straightforward — wrap, call next, augment — but the mistakes are subtle and often silent. Always call next, always match the exact method signature, and always check for other extensions on the same method before assuming your extension runs in isolation.

If you are migrating from overlayered code, start with the highest-risk customizations: posting logic, validation methods, and integration touchpoints. Convert them to CoR, validate in a sandbox, and remove the overlay. Each conversion reduces your upgrade risk and gets you closer to a fully extension-based codebase.

Comments

No comments yet. Be the first!