Tuesday, March 24, 2026

Model Context Protocol in D365 F&O — Developer Guide with Working Code

Version note: The dynamic Dynamics 365 ERP MCP server reached General Availability in February 2026. It requires version 10.0.47, 10.0.46 PQU-2, or 10.0.45 PQU-7. The static MCP server with 13 predefined tools is retired in 2026 — this article covers the dynamic server only.
If you have been building integrations with D365 F&O for any length of time, you know the pattern: write a custom service class, expose it as an OData endpoint, document it, and then maintain it through every platform update.

The Dynamics 365 ERP MCP Server replaces that entire approach for AI agent scenarios. It exposes your F&O environment — including your custom data entities, forms, buttons, and X++ classes — to any compatible AI agent through a single, standardised protocol called Model Context Protocol (MCP).

In this article I will cover what MCP is, how the three categories of tools work, show you working code and agent call sequences for each one, and walk through everything you need to set it up in your environment.



What is MCP?


Model Context Protocol (MCP) is an open standard developed by Anthropic that defines how AI agents communicate with external data sources and business applications. Instead of each AI tool building its own integration to each business system, MCP provides a universal protocol that any agent can use to discover and invoke capabilities in any MCP-compatible server.

Think of it as the USB-C standard for AI-to-application connectivity. Before USB-C, every device had its own connector. Before MCP, every AI integration had its own custom API.

In D365 F&O, the MCP server sits between your AI agent and your ERP environment:
Natural language prompt (user / autonomous trigger) │ ▼ AI Agent ←── reads tool descriptions, plans which tools to call (Copilot Studio / VS Code / Azure AI Foundry / custom client) │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Dynamics 365 ERP MCP Server │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Data Tools │ │ Form Tools │ │ Action Tools │ │ │ │ (7 tools) │ │ (13 tools) │ │ (2 tools) │ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ │ │ │ └─────────┼─────────────────┼──────────────────┼──────────┘ │ │ │ ▼ ▼ ▼ OData Entities Form View Model ICustomAPI classes (incl. custom) (same as client) (your X++ code) │ │ │ └─────────────────┴──────────────────┘ │ D365 F&O Database & Business Logic (security roles enforced on every call)
⚠️ Static MCP server is retired in 2026. The earlier version had 13 hardcoded tools and was built on the Dataverse connector framework. It is still available on environments running version 10.0.2263.17 and above but will be switched off. Migrate to the dynamic server now to avoid disruption.

Tool Category 1 — Data Tools (CRUD via OData / SQL)


Data tools are the fastest and most efficient way for an agent to create, read, update, or delete records. They work through the existing OData data entity layer — the same entities you expose for standard integrations. Custom data entities you have built are automatically discoverable.
ToolWhat it does
data_find_entity_typeDiscovers which OData entity types match the agent's intent. Returns multiple candidate hits — agent picks the right one.
data_get_entity_metadataReturns full schema for an entity — fields, keys, navigation properties. Must be called before create/update/delete.
data_find_entitiesQueries records via OData $filter expressions.
data_find_entities_sqlReplaces data_find_entities in version 10.0.48 onwards. Uses SQL syntax for more flexible querying.
data_create_entitiesCreates new records. Deep inserts (parent + child in one call) are not supported.
data_update_entitiesUpdates existing records by key.
data_delete_entitiesDeletes records by key.
Demo 1 — Read vendor open invoices using Data Tools                Working agent sequence
Show me all open vendor invoices for vendor US-001 that are overdue

Here is the exact sequence of tool calls the agent makes:

  • data_find_entity_typeQuery: "vendor invoice". Returns candidates including VendorInvoiceHeaderEntity, VendInvoiceInfoTable. Agent selects VendorInvoiceHeaderEntity.
  • data_get_entity_metadataGets schema for VendorInvoiceHeaderEntity — confirms fields: InvoiceVendorAccountNumber, DueDate, PaymentStatus, InvoiceAmount, CurrencyCode.
  • data_find_entitiesFilter: InvoiceVendorAccountNumber eq 'US-001' and DueDate lt 2026-06-03 and PaymentStatus eq 'None'. Returns matching invoice records as JSON.

The agent then formats the results as a natural language table for the user. Three tool calls, no custom code.

What this looks like in a Copilot Studio agent instruction

You are a finance assistant for Dynamics 365 F&O.

When the user asks about overdue vendor invoices:
1. Use data_find_entity_type to find the correct vendor invoice entity
2. Use data_get_entity_metadata to understand its fields
3. Use data_find_entities with a filter on vendor account, due date less
   than today, and payment status equal to 'None'
4. Present results as a summary with invoice number, amount,
   currency, and days overdue

Always use data tools for read operations — they are faster than form tools.
Demo 2 — Create a new vendor record using Data ToolsWorking agent sequence
Create a new vendor called "Contoso Supplies Ltd" with vendor group DOMESTIC and payment terms Net30
  • data_find_entity_typeQuery: "vendor". Selects VendVendorV2Entity as the correct entity for vendor master creation.
  • data_get_entity_metadataGets schema — identifies required fields: VendorAccountNumber (auto-generated), VendorOrganizationName, VendorGroupId, PaymentTermName.
  • data_create_entitiesPosts the new vendor record with the extracted field values.

The body the agent sends to data_create_entities looks like this:

{

     "entityName": "VendVendorV2Entity",

     "records": [

    {

        "VendorOrganizationName": "Contoso Supplies Ltd",

       "VendorGroupId": "DOMESTIC",

       "PaymentTermName": "Net30",

       "CurrencyCode": "USD"

     }
   ]
}

⚠️ Deep inserts are not supported

You cannot create a vendor and its bank accounts in a single data_create_entities call. Create the vendor first, then create bank account records separately using the appropriate entity, referencing the vendor account number returned from the first call.



Tool Category 2 — Form Tools (Button-driven business logic)


Form tools are the most powerful category. They let the agent interact with D365 F&O the same way a human user would — opening forms, setting field values, clicking action buttons, filtering grids, and saving records. This is not screen scraping. The agent works through server APIs that expose the application view model — the same model the client uses. Every form interaction respects the same security roles and runs the same business logic as a human click.

Use form tools when the operation involves button-driven business logic that is not available through a data entity — like releasing a purchase order, approving a workflow, posting a journal from a form, or any custom action button you have built through extensions.
ToolDescription
form_find_menu_itemFind a menu item in the navigation pane (filtered by security role)
form_open_menu_itemOpen a form via a menu item
form_find_controlsFind controls on the form. One search term per call — call multiple times for multiple controls
form_open_or_close_tabOpen or close a FastTab. Tabs are closed by default
form_set_control_valuesSet values on form controls. Do NOT use for lookup fields
form_open_lookupOpen a lookup field. Use this instead of set_control_values for lookup controls
form_filter_formApply a filter at form level
form_filter_gridFilter on a specific grid (supports "matches" operator only)
form_select_grid_rowSelect a row in a grid — required before row-level actions
form_click_controlClick a button or control, including custom extension buttons
form_sort_grid_columnSort a grid by a column
form_save_formSave the current form
form_close_formClose the current form
Demo 3 — Release a purchase requisition using Form ToolsWorking agent sequence — button-driven business logic
Release purchase requisition PR-000042 for approval

This is a classic form tools scenario — "Release" is a button on the Purchase Requisitions form that runs business logic. It is not a simple data entity update.

  • form_find_menu_itemSearch: "purchase requisitions". Returns the menu item for the All Purchase Requisitions form.
  • form_open_menu_itemOpens the All Purchase Requisitions form. View model returned includes grid with all requisitions the agent's security role can see.
  • form_filter_gridFilter: PurchReqId matches PR-000042. Grid narrows to the specific requisition.
  • form_select_grid_rowSelects the PR-000042 row. Required before any row-level button can be clicked.
  • form_find_controlsSearch: "Release". Finds the Release button on the Workflow menu group.
  • form_click_controlClicks the Release button. D365 F&O executes the release logic — same as a human clicking it. Workflow submission triggers.
  • form_close_formCloses the form cleanly.
⚠️ Always select the grid row before clicking a row-level button

Calling form_click_control on a row-level action without first calling form_select_grid_row results in the action either failing or acting on the wrong record. Row selection is a required step.

Demo 4 — Create a sales order with lines using Form ToolsMulti-step form interaction with FastTab handling
Create a sales order for customer US-004 with item D0001, quantity 10, and warehouse 11

This demonstrates the FastTab pattern — the most common form tools pitfall.

  • form_find_menu_itemSearch: "all sales orders". Finds the All Sales Orders menu item.
  • form_open_menu_itemOpens All Sales Orders form.
  • form_find_controlsSearch: "New". Finds the New button in the action pane.
  • form_click_controlClicks New. A new sales order header record is created and the form enters edit mode.
  • form_open_lookupOpens the Customer Account lookup. Searches for and selects US-004. Using form_open_lookup here — not form_set_control_values — because CustAccount is a lookup field.
  • form_open_or_close_tabOpens the "Lines" FastTab. FastTabs are closed by default — the agent cannot see or interact with the lines grid until this tab is explicitly opened.
  • form_find_controlsSearch: "Add line" inside the Lines tab. Finds the Add line button.
  • form_click_controlClicks Add line. A new sales order line row is created in the grid.
  • form_open_lookupOpens the Item Number lookup. Selects D0001.
  • form_set_control_valuesSets Quantity = 10. Quantity is a numeric field — form_set_control_values is correct here (not a lookup).
  • form_open_lookupOpens the Warehouse lookup. Selects warehouse 11.
  • form_save_formSaves the sales order. D365 F&O runs all standard validations — price defaulting, inventory checks, credit limit checks — exactly as it would for a human.
  • form_close_formCloses the form. Agent confirms the sales order number from the saved record.
✅ Key rule: form_open_lookup vs form_set_control_values

Use form_open_lookup for any field that shows a lookup icon in the UI — Customer Account, Item Number, Warehouse, Vendor, Currency, etc. Use form_set_control_values only for plain text, numeric, or date fields. Mixing these up is the most common form tool error.


Tool Category 3 — Action Tools (Custom X++ business logic)


Action tools are for scenarios where neither data entities nor form navigation can reach the logic you need to expose — for example, a custom calculation engine, a complex validation that spans multiple tables, or a business rule that is not triggered by any existing button.

You write an X++ class that implements the ICustomAPI interface, register it through the Dataverse Custom APIs form, and it becomes discoverable and invocable through api_find_actions and api_invoke_action.

ToolWhat it does
api_find_actionsReturns all ICustomAPI classes the agent's security role has access to
api_invoke_actionInvokes a specific class by its registered name, passing the required input parameters
Demo 5 — Custom X++ Action Tool: Vendor Credit Limit CheckerComplete X++ class + registration steps + agent invocation
Can we issue a new purchase order to vendor US-103? Check if they are within their credit limit.

Vendor credit limit checking requires joining VendTable, VendTrans, and a custom configuration table — logic that is not available through any standard data entity or form button. This is a perfect candidate for an Action Tool.

Step 1 — Write the X++ class

/// <summary>
/// AI Tool — checks whether a vendor is within their approved credit limit
/// based on current open purchase orders and outstanding invoices.
/// </summary>
[CustomAPI(
    'Check vendor credit limit',
    'Checks whether a vendor is within their approved credit limit. ' +
    'Returns the credit limit, current exposure (open POs + outstanding invoices), ' +
    'available credit, and whether a new purchase order can be issued.')]
[AIPluginOperationAttribute]
[DataContract]
public final class VendCreditLimitCheckAPI implements ICustomAPI
{
    // --- Input ---
    private VendAccount vendAccount;

    // --- Output ---
    private boolean     canIssueNewPO;
    private AmountMST   creditLimit;
    private AmountMST   currentExposure;
    private AmountMST   availableCredit;
    private str         statusMessage;

    // -------------------------------------------------------
    // Input Parameters
    // -------------------------------------------------------

    [CustomAPIRequestParameter('The vendor account number to check', true),
     DataMember('vendorAccountNumber')]
    public VendAccount parmVendAccount(VendAccount _vendAccount = vendAccount)
    {
        vendAccount = _vendAccount;
        return vendAccount;
    }

    // -------------------------------------------------------
    // Output Properties
    // -------------------------------------------------------

    [CustomAPIResponseProperty('Whether a new purchase order can be issued to this vendor'),
     DataMember('canIssueNewPO')]
    public boolean parmCanIssueNewPO(boolean _val = canIssueNewPO)
    {
        canIssueNewPO = _val;
        return canIssueNewPO;
    }

    [CustomAPIResponseProperty('Approved credit limit for the vendor in company currency'),
     DataMember('creditLimit')]
    public AmountMST parmCreditLimit(AmountMST _val = creditLimit)
    {
        creditLimit = _val;
        return creditLimit;
    }

    [CustomAPIResponseProperty('Current exposure: sum of open PO values + outstanding invoices'),
     DataMember('currentExposure')]
    public AmountMST parmCurrentExposure(AmountMST _val = currentExposure)
    {
        currentExposure = _val;
        return currentExposure;
    }

    [CustomAPIResponseProperty('Available credit: credit limit minus current exposure'),
     DataMember('availableCredit')]
    public AmountMST parmAvailableCredit(AmountMST _val = availableCredit)
    {
        availableCredit = _val;
        return availableCredit;
    }

    [CustomAPIResponseProperty('Plain language status message explaining the result'),
     DataMember('statusMessage')]
    public str parmStatusMessage(str _val = statusMessage)
    {
        statusMessage = _val;
        return statusMessage;
    }

    // -------------------------------------------------------
    // Business Logic
    // -------------------------------------------------------

    public void run(Args _args)
    {
        VendTable       vendTable;
        PurchTable      purchTable;
        VendTrans       vendTrans;
        AmountMST       openPOValue;
        AmountMST       outstandingInvoices;

        changecompany(curext())
        {
            vendTable = VendTable::find(this.parmVendAccount());

            if (!vendTable)
            {
                this.parmCanIssueNewPO(false);
                this.parmStatusMessage(
                    strFmt('Vendor %1 was not found.', this.parmVendAccount()));
                return;
            }

            // Read credit limit from VendTable extension field
            // Assumes a custom field VendCreditLimit added via table extension
            this.parmCreditLimit(vendTable.VendCreditLimit);

            if (this.parmCreditLimit() == 0)
            {
                // No limit configured — allow by default
                this.parmCanIssueNewPO(true);
                this.parmStatusMessage(
                    strFmt('Vendor %1 has no credit limit configured. New POs can be issued.',
                        this.parmVendAccount()));
                return;
            }

            // Sum open (not invoiced) purchase order values
            while select sum(PurchQty), sum(PurchPrice) from purchTable
                where purchTable.OrderAccount   == this.parmVendAccount()
                   && purchTable.PurchStatus    == PurchStatus::Received
            {
                openPOValue += purchTable.PurchQty * purchTable.PurchPrice;
            }

            // Sum outstanding (unpaid) vendor invoice amounts
            while select sum(AmountMST) from vendTrans
                where vendTrans.AccountNum  == this.parmVendAccount()
                   && vendTrans.TransType   == LedgerTransType::Purch
                   && vendTrans.Closed      == NoYes::No
                   && vendTrans.AmountMST   > 0
            {
                outstandingInvoices += vendTrans.AmountMST;
            }

            AmountMST exposure  = openPOValue + outstandingInvoices;
            AmountMST available = this.parmCreditLimit() - exposure;

            this.parmCurrentExposure(exposure);
            this.parmAvailableCredit(available);

            if (available > 0)
            {
                this.parmCanIssueNewPO(true);
                this.parmStatusMessage(
                    strFmt('Vendor %1 is within credit limit. ' +
                           'Limit: %2, Exposure: %3, Available: %4.',
                        this.parmVendAccount(),
                        this.parmCreditLimit(),
                        exposure,
                        available));
            }
            else
            {
                this.parmCanIssueNewPO(false);
                this.parmStatusMessage(
                    strFmt('Vendor %1 has exceeded their credit limit. ' +
                           'Limit: %2, Current exposure: %3. ' +
                           'New purchase orders cannot be issued.',
                        this.parmVendAccount(),
                        this.parmCreditLimit(),
                        exposure));
            }
        }
    }
}

Step 2 — Create the Action Menu Item

In Visual Studio, add an Action Menu Item to your project:

  • Name: VendCreditLimitCheckAPI
  • Object Type: Class
  • Object: VendCreditLimitCheckAPI

Step 3 — Create the Security Privilege

Add a Security Privilege (e.g. VendCreditLimitCheckAPIPrivilege) with the menu item as an entry point, Access Level = Create. Assign the privilege to a duty/role the agent identity will use (e.g. Accounts Payable role).

Step 4 — Build, flush cache, and synchronise

// After deploying your package, flush the AOD cache:
https://<your-env>.operations.dynamics.com/?cmp=USMF&mi=SysClassRunner&cls=SysFlushAOD

// Then in F&O:
// System Administration → Setup → Synchronize Dataverse Custom APIs → Synchronize

Your class should appear in the grid with status Registered.

Step 5 — Agent invocation sequence

  • api_find_actionsReturns all registered ICustomAPI classes the agent's role can access. Agent reads descriptions and identifies VendCreditLimitCheckAPI as matching the user's intent.
  • api_invoke_actionInvokes VendCreditLimitCheckAPI with vendorAccountNumber = "US-103". X++ run(Args _args) executes against live F&O data.

The agent receives the response JSON and presents it to the user:

{
  "canIssueNewPO": true,
  "creditLimit": 500000.00,
  "currentExposure": 312500.00,
  "availableCredit": 187500.00,
  "statusMessage": "Vendor US-103 is within credit limit. Limit: 500000, Exposure: 312500, Available: 187500."
}

Copilot responds to the user: "Yes, vendor US-103 is within their credit limit. They have $187,500 in available credit remaining from a $500,000 limit. You can proceed with the new purchase order."

✅ Write the [CustomAPI] description as if explaining to a non-developer

The agent orchestrator reads the description in the [CustomAPI] attribute to decide when to call this action. The more specific and plain-language the description, the more accurately the agent will route to it. Include what it checks, what inputs it needs, and what it returns — in plain English.


Configuration — setting up MCP in your environment

Prerequisites checklist

  • D365 F&O version 10.0.47, or 10.0.46 PQU-2, or 10.0.45 PQU-7
  • Tier 2+ environment or Unified Developer Environment — CHE (Cloud Hosted Environments) are not supported
  • Feature "Dynamics 365 ERP Model Context Protocol server" enabled in Feature Management (on by default)
  • Agent platform registered in Allowed MCP Clients
  • Agent identity assigned the System agent security role + task-appropriate roles

Allowed MCP Clients

By default only two clients can connect to your MCP server:

PlatformClient ID
Microsoft Copilot Studioa1bcd34-xyz-abcd1-abcde1-abcdefghiklj1
Visual Studio Codexyz6443-996ab-45xy2-91jfs-388abc96xyz56

To add another platform (Azure AI Foundry, Claude Desktop, a custom agent host):

  1. Register the application in Microsoft Entra ID and note the Application (Client) ID
  2. In F&O: System Administration → Setup → Allowed MCP Clients
  3. Add a new row with the Client ID, set Allowed = true

Agent security — the System agent role

Agent identities assigned the System agent role are exempt from D365 F&O user licensing. The role has no permissions — it is solely for license exemption. Assign additional roles to the agent identity that grant only the permissions needed for its tasks.

⚠️ Do not assign System Administrator to your agent identity

The MCP server excludes security management and user management forms, but assigning System Admin to an agent identity still violates least-privilege principles and creates an audit risk. Always scope agent roles to the minimum required for its tasks.

MCP server URL format

When connecting a compatible agent client (e.g. VS Code, Azure AI Foundry) to your environment, the MCP server URL follows this format:

https://<your-environment>.operations.dynamics.com/mcp

In VS Code, add this to your .vscode/mcp.json:

{
  "servers": {
    "d365fo": {
      "type": "http",
      "url": "https://<your-environment>.operations.dynamics.com/mcp",
      "gallery": false
    }
  }
}

Known limitations

1.English only. MCP responses and metadata always return in US English (en-us), even if the user's locale is different. Form labels may appear in the user's locale but MCP guidance is always English.
2.ISO date/time format. Dates, times, and numerics use ISO format and do not respect user locale.
3.Unsupported controls. Calendar controls, organisation chart controls, list view, availability view, HTML editor, image controls, radio buttons, time edit, and custom controls cannot be interacted with through form tools.
4.FastTabs are closed by default. The agent must call form_open_or_close_tab before accessing any controls inside a closed FastTab.
5.Grid filter operator limited to "matches" only. Date range operators (before, after, between) are not supported in form_filter_grid.
6.No attachments via standard controls. DocuUpload, FileUpload, and document viewer controls are not supported. A separate attachments API exists — see Microsoft Learn for MCP attachments.
7.System admin forms excluded. Feature Management, user management, security configuration, and Entra ID application management are excluded from MCP scope.
8.Not supported for F&O sidecar Copilot agent. Adding the ERP MCP server as a tool in the built-in Copilot for Finance and Operations sidecar agent is not yet officially supported and may produce errors.
9.Unavailable during environment servicing windows. All MCP tool calls fail during scheduled downtime. Design agents with retry logic for these periods.
10.Deep inserts not supported. data_create_entities cannot create parent and child records in a single call. Create them sequentially.

Conclusion

The Dynamics 365 ERP MCP Server is the most significant extensibility change to D365 F&O since the introduction of the Extension model. It shifts the integration surface from custom APIs you build and maintain to a universal protocol that any agent can use — without you writing a single connector.

The three tool categories each serve a clear purpose: Data Tools for efficient CRUD, Form Tools for button-driven business logic that mirrors exactly what a human would do, and Action Tools for custom X++ logic that neither of the first two can reach.

As an F&O developer, your role in this new architecture is to ensure your custom data entities are well-structured, your custom forms and buttons are logically named (because the agent navigates by name), and your custom X++ classes implement the ICustomAPI framework correctly so they are discoverable as Action Tools. That is the foundation of Agentic ERP — and the developers who understand how to build it are the ones who will shape the next generation of ERP implementations. 


That's all for now. Please let us know your questions or feedback in comments section !!!!

Tuesday, February 24, 2026

The D365 F&O Posting Framework — A Deep Dive for X++ Developers

Every D365 F&O developer has written posting code. Most of them have also spent hours debugging silent failures — journals that appear to post but leave the Posted flag as No, sales invoices that silently skip lines, or voucher transactions that hit the wrong ledger account because a dimension defaulting step was skipped.

The root cause in almost every case is the same: the developer called the wrong method, skipped an initialisation step, or bolted custom logic onto a posting process without understanding where in the execution pipeline it belongs.

This article maps the D365 F&O posting framework from the inside out — the architecture layers, the key classes, verified X++ patterns for journal and sales invoice posting, and the right extension points for each scenario.


The two posting scenarios we will focus on : -

D365 F&O has two fundamentally different posting mechanisms, and choosing the wrong one for your scenario is the first place developers go wrong.
ScenarioEntry ClassUsed For
Journal postingLedgerJournalCheckPostGeneral journals, vendor payment journals, customer payment journals, fixed asset journals — any transaction that lives in LedgerJournalTable / LedgerJournalTrans
Document posting (FormLetter)SalesFormLetter / PurchFormLetterSales order invoices, packing slips, confirmations, purchase order invoices, product receipts — transactions that generate subledger journals

The journal pipeline creates ledger entries directly. The FormLetter pipeline first creates subledger journal entries via the SourceDocument framework, which are then posted to the general ledger through the SubledgerJournalizer. Understanding this distinction determines where you place your extensions.


Architecture — what happens between "Post" and "Voucher posted"

User / Code trigger
Button click on form,runOperation()call in X++, or batch job execution
Check & Validate
LedgerJournalCheckPost::newLedgerJournalTable()validates lines, checks mandatory fields, verifies period status, checks posting restrictions
Voucher generation
LedgerVoucher/LedgerVoucherObject/LedgerVoucherTransObject— assembles balanced voucher entries in temporary storage
Subledger journal
SubledgerJournalizerwrites toSubledgerJournalAccountEntry— used in FormLetter pipeline only, transfers to GL via batch or synchronous transfer
General Ledger commit
Writes toGeneralJournalEntry/GeneralJournalAccountEntry.LedgerJournalTable.Postedset toYes

The critical insight: custom logic inserted at the wrong layer causes data inconsistency. Adding GL entries after the SubledgerJournalizer step but before the GL commit means your entries bypass subledger reconciliation. Adding them before validation means they can be rolled back silently if the journal fails checks.


Scenario 1 — Journal posting with LedgerJournalCheckPost

The correct pattern for triggering posting from X++

The single most misused method in journal posting is calling post() or run() directly. The correct entry point is runOperation(), which orchestrates both validation and posting in one call.


public static void postJournalById(LedgerJournalId _journalNum)
{
    LedgerJournalTable      ledgerJournalTable;
    LedgerJournalCheckPost  ledgerJournalCheckPost;

    ledgerJournalTable = LedgerJournalTable::find(_journalNum);

    if (!ledgerJournalTable)
    {
        throw error(strFmt("Journal %1 not found.", _journalNum));
    }

    if (ledgerJournalTable.Posted == NoYes::Yes)
    {
        info(strFmt("Journal %1 is already posted.", _journalNum));
        return;
    }

    // NoYes::Yes = post (not just validate)
    ledgerJournalCheckPost = LedgerJournalCheckPost::newLedgerJournalTable(
        ledgerJournalTable,
        NoYes::Yes);

    ledgerJournalCheckPost.runOperation();

    // Reread to confirm posted status — the buffer passed in is now stale
    ledgerJournalTable.reread();

    if (ledgerJournalTable.Posted == NoYes::Yes)
    {
        info(strFmt("Journal %1 posted successfully.", _journalNum));
    }
    else
    {
        warning(strFmt("Journal %1 may not have posted. Review the infolog.", _journalNum));
    }
}


Why reread() after runOperation()

The LedgerJournalTable buffer you pass into newLedgerJournalTable() is a snapshot. The Posted flag is written to the database during posting — your local buffer does not update automatically. Always call .reread() on the buffer before checking Posted.


Validate only — without posting

Pass NoYes::No as the second parameter to run validation without committing the post. This is useful in integration scenarios where you want to surface errors before triggering the actual post.


// Validate without posting
ledgerJournalCheckPost = LedgerJournalCheckPost::newLedgerJournalTable(
    ledgerJournalTable,
    NoYes::No);

ledgerJournalCheckPost.runOperation();

// Check infolog for errors — no vouchers were committed
if (infolog.num() > 0)
{
    // surface or log errors
}


Creating a general journal header and lines before posting

Creating the journal correctly is just as important as posting it. The most common mistake is populating LedgerJournalTrans fields manually and calling insert() without using initFromLedgerJournalName() on the header and initValue() on the lines. This skips defaulting logic and produces journals that post but have incorrect dimensions, due dates, or currency exchange rates.

public static void createAndPostGeneralJournal()
{
    LedgerJournalTable      ledgerJournalTable;
    LedgerJournalTrans      ledgerJournalTrans;
    LedgerJournalCheckPost  ledgerJournalCheckPost;
    LedgerJournalName       ledgerJournalName;
    NumberSeq               numberSeq;

    // 1. Find an active journal name of type Daily
    select firstonly ledgerJournalName
        where ledgerJournalName.JournalName == 'GenJrn';

    if (!ledgerJournalName)
    {
        throw error("Journal name 'GenJrn' not found.");
    }

    ttsBegin;

    // 2. Create the journal header
    ledgerJournalTable.clear();
    ledgerJournalTable.JournalName = ledgerJournalName.JournalName;
    ledgerJournalTable.initFromLedgerJournalName();   // ← critical: sets journal type, voucher series, posting layer
    ledgerJournalTable.Name        = "Auto-posted adjustment";
    ledgerJournalTable.insert();

    // 3. Create the debit line
    ledgerJournalTrans.clear();
    ledgerJournalTrans.initValue();                   // ← sets defaults: currency, exchange rate, company
    ledgerJournalTrans.JournalNum   = ledgerJournalTable.JournalNum;
    ledgerJournalTrans.TransDate    = today();
    ledgerJournalTrans.AccountType  = LedgerJournalACType::Ledger;

    // LedgerDimension must be a valid RecId from DimensionAttributeValueCombination
    // Use LedgerDimensionFacade or LedgerDefaultAccountHelper to build it properly
    ledgerJournalTrans.LedgerDimension = LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId('110180');

    ledgerJournalTrans.AmountCurDebit  = 1000.00;
    ledgerJournalTrans.CurrencyCode    = CompanyInfo::standardCurrency();
    ledgerJournalTrans.ExchRate        = Currency::exchRate(ledgerJournalTrans.CurrencyCode);
    ledgerJournalTrans.Txt             = "Test debit entry";

    // Voucher must be obtained from the number sequence on the journal name
    numberSeq = NumberSeq::newGetVoucherFromCode(
        LedgerJournalName::find(ledgerJournalTable.JournalName).VoucherSeries);
    ledgerJournalTrans.Voucher = numberSeq.voucher();

    ledgerJournalTrans.LineNum  = LedgerJournalTrans::lastLineNum(ledgerJournalTrans.JournalNum) + 1;
    ledgerJournalTrans.insert();

    // 4. Create the offsetting credit line
    ledgerJournalTrans.clear();
    ledgerJournalTrans.initValue();
    ledgerJournalTrans.JournalNum      = ledgerJournalTable.JournalNum;
    ledgerJournalTrans.TransDate       = today();
    ledgerJournalTrans.AccountType     = LedgerJournalACType::Ledger;
    ledgerJournalTrans.LedgerDimension = LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId('140270');
    ledgerJournalTrans.AmountCurCredit = 1000.00;
    ledgerJournalTrans.CurrencyCode    = CompanyInfo::standardCurrency();
    ledgerJournalTrans.ExchRate        = Currency::exchRate(ledgerJournalTrans.CurrencyCode);
    ledgerJournalTrans.Txt             = "Test credit entry";
    ledgerJournalTrans.Voucher         = numberSeq.voucher(); // same voucher — debit and credit must balance
    ledgerJournalTrans.LineNum         = LedgerJournalTrans::lastLineNum(ledgerJournalTrans.JournalNum) + 1;
    ledgerJournalTrans.insert();

    ttsCommit;

    // 5. Post
    ledgerJournalCheckPost = LedgerJournalCheckPost::newLedgerJournalTable(
        ledgerJournalTable,
        NoYes::Yes);

    ledgerJournalCheckPost.runOperation();

    ledgerJournalTable.reread();

    if (ledgerJournalTable.Posted == NoYes::Yes)
    {
        info(strFmt("Journal %1 created and posted successfully.", ledgerJournalTable.JournalNum));
    }
⚠️ Never set LedgerDimension by hardcoding an account string directly

LedgerJournalTrans.LedgerDimension is a RecId pointing to DimensionAttributeValueCombination — not a string. Assigning an account number string directly compiles but results in a zero RecId at runtime, causing the line to post to an unresolved account. Always use LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId() or LedgerDimensionFacade::serviceCreateLedgerDimension() to build the RecId properly.

⚠️ The debit and credit on the same voucher must balance to zero

The general ledger enforces that the sum of all AmountMST values under a single voucher equals zero. If your journal lines on the same voucher do not balance, the post will fail with "Voucher is not balanced." Use the same numberSeq.voucher() value for all lines that belong to the same balanced entry.


Scenario 2 — Document posting with SalesFormLetter


The posting class hierarchy

When a sales order is invoiced, the entry point is SalesFormLetter. This is a factory class — you construct it with SalesFormLetter::construct(DocumentStatus::Invoice), not by instantiating SalesFormLetter_Invoice directly. The same pattern applies across all document types.
Document StatusFormLetter ClassJournal Table Created
ConfirmationSalesFormLetter_ConfirmCustConfirmJour
Picking ListSalesFormLetter_PickingListWMSPickingRoute
Packing SlipSalesFormLetter_PackingSlipCustPackingSlipJour
InvoiceSalesFormLetter_InvoiceCustInvoiceJour

The below mentioned code helps us to post sales invoice through x++ : -


public static void postSalesInvoice(SalesId _salesId)
{
    SalesTable              salesTable;
    SalesFormLetter_Invoice salesFormLetter;

    salesTable = SalesTable::find(_salesId);

    if (!salesTable)
    {
        throw error(strFmt("Sales order %1 not found.", _salesId));
    }

    if (salesTable.SalesStatus == SalesStatus::Invoiced)
    {
        info(strFmt("Sales order %1 is already fully invoiced.", _salesId));
        return;
    }

    ttsBegin;

    // construct() returns the correct subclass based on DocumentStatus
    salesFormLetter = SalesFormLetter::construct(DocumentStatus::Invoice);

    salesFormLetter.update(
        salesTable,              // the sales order record
        SystemDateGet(),         // invoice date
        SalesUpdate::All,        // update all uninvoiced lines
        AccountOrder::None,      // account order (None = use default)
        false,                   // printFormLetter — false to suppress print dialog
        true                     // specQty — true means use actual qty from packing slip
    );

    ttsCommit;

    // Reread to confirm
    salesTable.reread();

    if (salesTable.SalesStatus == SalesStatus::Invoiced)
    {
        info(strFmt("Sales order %1 invoiced successfully.", _salesId));
    }
}
✅ SalesUpdate::All vs SalesUpdate::PackingSlip

SalesUpdate::All invoices all lines regardless of packing slip status. SalesUpdate::PackingSlip invoices only lines that have been packing-slipped. In most integration scenarios where posting is triggered from an external system, SalesUpdate::PackingSlip is safer — it follows the same business process the user would follow manually.

Posting a packing slip before invoicing


public static void postPackingSlip(SalesId _salesId)
{
    SalesTable              salesTable;
    SalesFormLetter         salesFormLetter;

    salesTable = SalesTable::find(_salesId);

    ttsBegin;

    salesFormLetter = SalesFormLetter::construct(DocumentStatus::PackingSlip);

    salesFormLetter.update(
        salesTable,
        SystemDateGet(),
        SalesUpdate::PickingList,  // only lines that are picked
        AccountOrder::None,
        false,
        false
    );

    ttsCommit;
}
 

Extending the posting pipeline with Chain of Command

This is where most real-world customisation work happens. The rules are:Always call next methodName() — skipping it breaks the standard posting logic entirely
Pre-logic before next runs inside the same transaction as the standard logic
Post-logic after next also runs in the same transaction — a throw here rolls back the whole post.
Use pre-logic for validation (can abort the post cleanly). Use post-logic for side effects (writing to custom tables after the post succeeds)



Extension 1 — Add a custom validation before journal posting

The validate() method on LedgerJournalCheckPost runs before any vouchers are committed. This is the correct place to add business-rule checks that should block posting.

[ExtensionOf(classStr(LedgerJournalCheckPost))]
final class LedgerJournalCheckPost_CustomValidation_Extension
{
    public boolean validate()
    {
        boolean ret;

        // Run standard validation first
        ret = next validate();

        // Only add our check if standard validation passed
        if (ret)
        {
            LedgerJournalTrans  ledgerJournalTrans;
            LedgerJournalTable  journalTable = this.parmLedgerJournalTable();

            // Example: block posting if any line exceeds a custom threshold
            while select ledgerJournalTrans
                where ledgerJournalTrans.JournalNum == journalTable.JournalNum
                   && ledgerJournalTrans.AmountCurDebit > 500000
            {
                ret = checkFailed(strFmt(
                    "Line %1 exceeds the maximum allowed single-line amount of 500,000. Journal %2 cannot be posted.",
                    ledgerJournalTrans.LineNum,
                    journalTable.JournalNum));
            }
        }

        return ret;
    }
}

Extension 2 — Write to a custom audit table after journal posting

The runOperation() method completes after the post is committed. Extending it post-next gives you a guaranteed hook that only runs on a successful post.


[ExtensionOf(classStr(LedgerJournalCheckPost))]
final class LedgerJournalCheckPost_PostingAudit_Extension
{
    public void runOperation()
    {
        LedgerJournalTable journalTable = this.parmLedgerJournalTable();
        LedgerJournalId    journalNum   = journalTable.JournalNum;

        // Run the standard post
        next runOperation();

        // After standard post completes — reread to check actual status
        journalTable.reread();

        if (journalTable.Posted == NoYes::Yes)
        {
            // Write to custom audit log
            CustomPostingAuditLog auditLog;

            auditLog.JournalNum     = journalNum;
            auditLog.PostedBy       = curUserId();
            auditLog.PostedDateTime = DateTimeUtil::utcNow();
            auditLog.PostedAmount   = this.totalAmountPosted(journalNum);
            auditLog.insert();
        }
    }

    private AmountMST totalAmountPosted(LedgerJournalId _journalNum)
    {
        GeneralJournalEntry         gje;
        GeneralJournalAccountEntry  gjae;
        AmountMST                   total;

        // Sum the absolute debit amounts from GL entries for this journal
        while select sum(AccountingCurrencyAmount) from gjae
            exists join gje
                where gje.RecId          == gjae.GeneralJournalEntry
                   && gje.JournalNumber  == _journalNum
                   && gjae.AccountingCurrencyAmount > 0
        {
            total = gjae.AccountingCurrencyAmount;
        }

        return total;
    }
}

Extension 3 — Extend sales invoice posting to populate a custom field

The SalesFormLetter_Invoice class has a createJournalHeader() method that runs when the CustInvoiceJour record is being created. This is the correct place to stamp custom fields onto the invoice journal header.



[ExtensionOf(classStr(SalesFormLetter_Invoice))]
final class SalesFormLetter_Invoice_CustomField_Extension
{
    protected void createJournalHeader(
        SalesParmUpdate     _salesParmUpdate,
        SalesTable          _salesTable,
        CustInvoiceJour     _custInvoiceJour)
    {
        // Call standard logic first — header record is populated by next
        next createJournalHeader(_salesParmUpdate, _salesTable, _custInvoiceJour);

        // Stamp custom field from SalesTable extension onto the invoice journal
        // Assumes SalesTable has a custom field CustomContractRef added via extension
        SalesTable salesTableLocal = SalesTable::find(_salesTable.SalesId);

        if (salesTableLocal.CustomContractRef)
        {
            _custInvoiceJour.selectForUpdate(true);
            _custInvoiceJour.CustomContractRef = salesTableLocal.CustomContractRef;
            _custInvoiceJour.doUpdate();
        }
    }
}
⚠️ Use doUpdate() not update() inside posting CoC extensions

update() on a table inside a posting CoC will trigger validateWrite() and modifiedField() again, which can cause recursion or secondary side effects mid-post. Use doUpdate() when you need to update a record that is already in-flight inside the posting pipeline.




Posting with error handling — the production pattern

In batch or integration contexts, a posting failure on one record must not stop processing of the remaining records. The correct pattern uses a try/catch per journal with infolog capture, so errors are logged and processing continues.


public static void postMultipleJournals(container _journalNums)
{
    LedgerJournalTable      ledgerJournalTable;
    LedgerJournalCheckPost  ledgerJournalCheckPost;
    int                     infologLine;
    int                     i;
    LedgerJournalId         journalNum;

    for (i = 1; i <= conLen(_journalNums); i++)
    {
        journalNum = conPeek(_journalNums, i);

        ledgerJournalTable = LedgerJournalTable::find(journalNum);

        if (!ledgerJournalTable || ledgerJournalTable.Posted == NoYes::Yes)
        {
            continue;
        }

        // Capture infolog line before each attempt
        infologLine = Global::infologLine();

        try
        {
            ledgerJournalCheckPost = LedgerJournalCheckPost::newLedgerJournalTable(
                ledgerJournalTable,
                NoYes::Yes);

            ledgerJournalCheckPost.runOperation();

            ledgerJournalTable.reread();

            if (ledgerJournalTable.Posted == NoYes::Yes)
            {
                info(strFmt("Journal %1 posted successfully.", journalNum));
            }
            else
            {
                // Post returned without exception but journal is not marked posted
                // Capture infolog messages for this journal
                str errorMessages = RetailTransactionServiceUtilities::getInfologMessages(infologLine);
                warning(strFmt("Journal %1 did not post. Messages: %2", journalNum, errorMessages));
            }
        }
        catch (Exception::Error)
        {
            // Capture the actual error from infolog
            str errorMessages = RetailTransactionServiceUtilities::getInfologMessages(infologLine);
            error(strFmt("Error posting journal %1: %2", journalNum, errorMessages));

            // Continue to next journal — exception is swallowed per record
        }
        catch (Exception::Deadlock)
        {
            // Retry on deadlock
            retry;
        }
    }
}
✅ Always capture infologLine before the try block

Calling Global::infologLine() before your try block records the current position in the infolog. If the post fails, you can pass this to RetailTransactionServiceUtilities::getInfologMessages(infologLine) to retrieve only the messages generated by this specific post attempt — not the entire infolog since the session started. This is the same enterprise exception handling pattern covered in the earlier article on this blog.




The LedgerVoucher API — when to use it directly

Occasionally you need to write GL entries without going through a journal or a FormLetter — for example, in a custom integration that posts financial adjustments programmatically. The LedgerVoucher / LedgerVoucherObject / LedgerVoucherTransObject API is the correct approach for this.
ClassResponsibility
LedgerVoucherTop-level container — manages one or more vouchers. Controls DetailSummary mode and the SysModule context.
LedgerVoucherObjectRepresents a single balanced voucher. Holds a voucher number, transaction date, and correction flag.
LedgerVoucherTransObjectRepresents a single GL line within a voucher — account, dimension, currency, amount.


public static void postDirectGLAdjustment(
    MainAccountNum  _debitAccount,
    MainAccountNum  _creditAccount,
    AmountCur       _amount,
    str             _description)
{
    LedgerVoucher           ledgerVoucher;
    LedgerVoucherObject     ledgerVoucherObject;
    LedgerVoucherTransObject ledgerVoucherTransObject;
    NumberSeq               numberSeq;
    Voucher                 voucher;
    LedgerDimensionAccount  debitDimension;
    LedgerDimensionAccount  creditDimension;

    // Build ledger dimension RecIds from main account numbers
    debitDimension  = LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId(_debitAccount);
    creditDimension = LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId(_creditAccount);

    // Get a voucher from the appropriate number sequence
    numberSeq = NumberSeq::newGetVoucherFromCode('Ledger_1');
    voucher   = numberSeq.voucher();

    ttsBegin;

    // 1. Create the top-level LedgerVoucher container
    ledgerVoucher = LedgerVoucher::newLedgerPost(
        DetailSummary::Detail,
        SysModule::Ledger,
        'Ledger_1');           // voucher series code

    // 2. Create a voucher object (one balanced entry)
    ledgerVoucherObject = LedgerVoucherObject::newVoucher(
        voucher,
        today(),
        SysModule::Ledger,
        LedgerTransType::None);

    ledgerVoucher.addVoucher(ledgerVoucherObject);

    // 3. Add the debit transaction line
    ledgerVoucherTransObject = LedgerVoucherTransObject::newCreateTrans(
        ledgerVoucherObject,
        LedgerPostingType::LedgerJournal,
        debitDimension,
        CompanyInfo::standardCurrency(),
        _amount,        // AmountCurDebit
        0,              // AmountCurCredit
        0,              // sourceTableId
        0);             // sourceRecId

    ledgerVoucherTransObject.parmTransTxt(_description);
    ledgerVoucher.addTrans(ledgerVoucherTransObject);

    // 4. Add the credit transaction line (negated amount)
    ledgerVoucherTransObject = LedgerVoucherTransObject::newCreateTrans(
        ledgerVoucherObject,
        LedgerPostingType::LedgerJournal,
        creditDimension,
        CompanyInfo::standardCurrency(),
        0,              // AmountCurDebit
        _amount,        // AmountCurCredit
        0,
        0);

    ledgerVoucherTransObject.parmTransTxt(_description);
    ledgerVoucher.addTrans(ledgerVoucherTransObject);

    // 5. End() commits all vouchers to the GL
    ledgerVoucher.end();

    ttsCommit;

    info(strFmt("Voucher %1 posted: %2 DR %3, CR %4 for amount %5",
        voucher, _debitAccount, _creditAccount, _amount));
}
⚠️ LedgerVoucher.end() must be called inside the same ttsBegin/ttsCommit

The LedgerVoucher API stages entries in memory. Calling end() flushes them to the database. If end() is called outside a transaction scope, the write behaviour is unpredictable. Always bracket the entire sequence — newLedgerPost through end() — inside a single ttsBegin / ttsCommit block.




Conclusion

The posting framework in D365 F&O is not complicated once you understand that it has two distinct pipelines — journal posting through LedgerJournalCheckPost, and document posting through SalesFormLetter — and that each pipeline has specific, correct entry points.

Most production bugs in posting customisations come from three places: skipping field initialisation when creating journal lines, checking Posted on a stale buffer instead of calling reread(), and placing custom logic outside the transaction scope of the post so it either runs when the post fails or gets rolled back when it should not.

Apply the patterns in this article and your posting code will be solid, upgrade-safe, and diagnosable when things go wrong — because with a properly structured try/catch and infolog capture, you will always know exactly what failed and why.


That's all for now. Please let us know your questions or feedback in comments section !!!!

Tuesday, January 20, 2026

Performance Tuning in D365 Finance & Operations — A Deep Dive from the Field


Patterns, Pitfalls, and Proven X++ Techniques for Enterprise-Scale Systems

Performance problems in Dynamics 365 Finance & Operations rarely start with “the system is slow.”

They start with:

  • Batch jobs that grow from 5 minutes to 5 hours

  • Reports that work in UAT and time out in production

  • Integrations that collapse under real data volumes

  • Posting processes that lock half the database

By the time performance becomes visible, it is already an architectural problem.

This article is not about generic advice like “add an index.”


It is a deep dive into how performance actually breaks in D365 F&O, how to diagnose it, and how to design and code for performance from day one.


1. The First Rule of Performance: Design Before Optimisation


In all Dynamics 365 F&O projects, the biggest performance gains almost always come from:

  • Reducing database round trips

  • Eliminating row-by-row processing

  • Controlling transaction scope

  • Using the right execution model (set-based vs procedural)


No index can fix a poorly designed processing pattern.

Before touching code, always identify:

  • Expected record volumes (10k vs 10M changes everything)

  • Execution mode (interactive, batch, integration)

  • Concurrency requirements

  • Failure and restart expectations


2. Diagnosing Performance Correctly


Before optimizing, capture facts:

  • Use Trace Parser for SQL call analysis

  • Use Execution history for batch patterns

  • Enable SQL insights / Application Insights

  • Inspect generated SQL (not just X++)

Performance tuning without tracing is guesswork.


3. The Most Common Performance Killers


From real implementations, these patterns cause most escalations:

  • Nested while select loops

  • Large ttsBegin/ttsCommit scopes

  • Repeated find() calls inside loops

  • Non-indexed status and date filters

  • Business logic embedded directly in forms

  • Heavy processing in post handlers


4. Row-by-Row Processing vs Set-Based Processing


❌ Poor Pattern (RBAR – Row By Agonizing Row)


while select forUpdate salesTable
    where salesTable.Status == SalesStatus::Backorder
{
    salesTable.CustomProcessed = NoYes::Yes;
    salesTable.update();
}

Problems:

  • One SQL call per row

  • Excessive locking

  • Transaction log pressure


 ✅ Optimized Pattern (Set-Based)


 ttsBegin;

 update_recordset salesTable
    setting CustomProcessed = NoYes::Yes
    where salesTable.Status == SalesStatus::Backorder;

 ttsCommit;


Benefits:

  • Single SQL statement

  • Minimal locks

  • Orders of magnitude faster

Architectural rule:

If business logic does not require per-record decisions, it should not be in a loop.


 

5. Eliminating Nested Selects with Exists Joins


❌ Poor Pattern


while select salesTable
{
    select firstOnly custTable
        where custTable.AccountNum == salesTable.CustAccount;

    if (custTable.Blocked == CustVendorBlocked::No)
    {
        // process
    }
}


This executes one SQL query per row.


 ✅ Optimized Pattern


 while select salesTable
    exists join custTable
        where custTable.AccountNum == salesTable.CustAccount
           && custTable.Blocked == CustVendorBlocked::No
 {
    // process
 }


Benefits:

  • One optimized SQL statement

  • SQL Server handles filtering

  • Dramatically reduced round trips


 

6. Transaction Scope: The Silent Performance Killer


   
Large ttsBegin/ttsCommit blocks cause:
  • Lock escalation

  • Blocking

  • Long rollbacks

  • TempDB pressure

 

❌ Dangerous Pattern


ttsBegin;

while select forUpdate buffer
{
    this.process(buffer);
    buffer.update();
}

ttsCommit;


If this fails after 200,000 rows, everything rolls back.


✅ Optimized Pattern


while select forUpdate buffer
{
    ttsBegin;
    this.process(buffer);
    buffer.update();
    ttsCommit;
}

Or even better — chunk-based commits.


7. High-Performance Chunk Processing Pattern


This pattern is used in large-scale posting engines and integrations.


public static void processInChunks()
{
    MyTable buffer;
    int processed;

    while true
    {
        processed = 0;

        ttsBegin;

        while select firstFast forUpdate buffer
            where buffer.Processed == NoYes::No
        {
            MyBusinessService::process(buffer);
            buffer.Processed = NoYes::Yes;
            buffer.update();

            processed++;

            if (processed >= 500)
                break;
        }

        ttsCommit;

        if (processed == 0)
            break;
    }
}


Benefits:

  • Controlled locking

  • Safe restart

  • Stable memory footprint

  • Predictable throughput

This design is far more important than micro-optimizations.


8. Caching and Find Patterns That Actually Matter


❌ Repeated Finds


while select salesLine
{
    custTable = CustTable::find(salesLine.CustAccount);
}

✅ Cached Lookups


Map custCache = new Map(Types::String, Types::Class);

while select salesLine
{
    custTable = custCache.lookup(salesLine.CustAccount);

    if (!custTable)
    {
        custTable = CustTable::find(salesLine.CustAccount);
        custCache.insert(salesLine.CustAccount, custTable);
    }
}


This single pattern has fixed more performance issues than most indexes.

9. A Real Performance Refactor Example


❌ Original Code (Production Issue)


while select staging
{
    select firstOnly target
        where target.Key == staging.Key;

    if (!target)
    {
        target = new TargetTable();
        target.Key = staging.Key;
        target.insert();
    }
}


Issues:

  • 1 select per row

  • No batching

  • No restart control


✅ Performance Refactor


while true
{
    int processed = 0;

    ttsBegin;

    while select firstFast forUpdate staging
        where staging.Processed == NoYes::No
    {
        if (!TargetTable::exists(staging.Key))
        {
            TargetTable::createFromStaging(staging);
        }

        staging.Processed = NoYes::Yes;
        staging.update();

        processed++;

        if (processed >= 300)
            break;
    }

    ttsCommit;

    if (processed == 0)
        break;
}

This single change:

  • Removed timeouts

  • Eliminated deadlocks

  • Made the process restartable

  • Reduced execution time by hours


10. Architect’s Performance Checklist


Before approving any solution:

  • Are queries set-based wherever possible?

  • Are status/date fields indexed?

  • Is transaction scope controlled?

  • Can the job restart without data fixes?

  • Are repeated finds eliminated?

  • Are batch jobs parallel-safe?

  • Is heavy logic isolated from UI?


If any answer is “no,” performance problems are already there and needs to be fixed immediately.


Conclusion

In Dynamics 365 Finance & Operations, performance tuning is not a late-stage activity.

It is a design discipline.

The systems that scale are not the ones with the most indexes.
They are the ones built on correct processing patterns.

When performance engineering becomes part of how you think — not how you react — you move from developer to architect.



That's all for now. Please let us know your questions or feedback in comments section !!!!

Wednesday, December 24, 2025

Enterprise Grade Exception Handling in Dynamics 365 Finance & Operations through X++

 Exception handling in Dynamics 365 Finance & Operations (D365 F&O) is often implemented in a simple manner with a simple motive to catch exception messages.

Most developers rely on try…catch(Exception::Error) and log generic messages, which makes production support and troubleshooting extremely difficult.

In enterprise projects, this approach is not sufficient.

Let us explore a robust, Infolog-driven exception handling pattern that allows developers to capture exact system-generated error messages, identify exception severity, and build supportable, production-ready solutions.


Why Basic Exception Handling Fails in Production

A typical implementation looks like this:

try
{
// Code responsible for raising exception.
}
catch (Exception::Error) { error("An error occurred"); }


Disadvantages of using this particular exception handling pattern : - 
 

  • The actual system error is lost
  • Infolog messages are not captured
  • Support teams cannot diagnose issues as the actual error is not caught.
  • Logs are meaningless for audits and Root Cause Analysis


Understanding Infolog in D365 F&O

The Infolog is the system’s primary diagnostic mechanism.
Whenever an error, warning, or info message is raised, D365 FO writes structured entries into Infolog.

If we can programmatically read Infolog, we gain access to:

  • Exact error text

  • Severity (Info / Warning / Error)

  • Execution context


Using standard class can help to get the exact error messages such as here in the below mentioned code example exception handling is used to get exact error messages using RetailTransactionServiceUtilities class. Also sometimes exceptions don't get caught by all catch blocks so we have to use finally block to catch the exact message by filtering the type of Exception as error. 

Let's take a closer look at the below mentioned code : - 

public static void main(Args args)
{
    int                         infologLine;
    str                         errorMsg;
    SysInfologEnumerator        enumerator;
    SysInfologMessageStruct     message;
    Exception                   ex;

    try
    {
        infolog.clear();
        infologLine = Global::infologLine();

        // Business logic goes here
    }
    catch (Exception::Error)
    {
        errorMsg = RetailTransactionServiceUtilities::getInfologMessages(infologLine);
        // Store this value in a custom exception log table
    }
    finally
    {
        enumerator = SysInfologEnumerator::newData(
                        infolog.copy(infologLine + 1, infolog.num()));

        errorMsg = RetailTransactionServiceUtilities::getInfologMessages(infologLine);
        // Store this value in a custom exception log table

        while (enumerator.moveNext())
        {
            ex = enumerator.currentException();
            message = new SysInfologMessageStruct(enumerator.currentMessage());

            if (ex == Exception::Error)
            {
                // Process only Error-level messages
                // Store the value stored in errormsg variable in a custom exception log table
            }
        }
    }
}

Conclusion

Enterprise D365 F&O solutions demand traceability, diagnostics, and supportability.
This Infolog-based exception handling pattern is a foundational step toward building production-grade implementations.


That's all for now. Please let us know your questions or feedback in comments section !!!!

Importing Excel Dates in D365 F&O through X++ without the Apostrophe Trick

  We often get a requirement to create excel upload custom functionality in x++ . In this post we will see how to handle Excel OLE Automatio...