Saturday, July 25, 2026

Financial Dimension Framework in D365 F&O — Complete X++ Developer Guide

The Financial Dimension Framework is one of the most misunderstood parts of D365 F&O development. Most developers encounter it for the first time when a journal line posts to the wrong account because a dimension was not correctly set, or when they need to set a cost centre value on a custom record and cannot figure out why assigning a string does not work.

The reason is that dimensions in D365 F&O are not stored as strings. They are stored as RecId references to normalised sets in the dimension framework tables. Understanding this storage model — and knowing which of the dozen-plus facade classes to use in each situation — is the entire battle.

This article covers the framework from the inside out: the data model, the difference between default dimensions and ledger dimensions, every key API with its correct signature, and a set of production-grade X++ patterns for the scenarios you will encounter in real implementations.

The two dimension types you must distinguish

Every confusion in the dimension framework traces back to not distinguishing between these two things:

ConceptType / FieldWhat it storesExample
Default DimensionDimensionDefault (RefRecId)A set of dimension attribute values with no main account. Used on master records (Vendor, Customer, Item, Department) as a source for defaulting.VendTable.DefaultDimension, PurchTable.DefaultDimension
Ledger DimensionLedgerDimensionAccount (RefRecId)A full account combination — main account plus dimension values — constrained by the account structure. Used on transaction lines.LedgerJournalTrans.LedgerDimension, LedgerJournalTrans.OffsetLedgerDimension

Both types are stored as RecId values pointing into the dimension framework tables. You cannot read or write them with plain strings. You must use the facade APIs.

The data model — what is behind the RecId

Understanding what the RecId actually points to explains why the framework behaves the way it does — especially its immutability.

Default Dimension (DimensionDefault field on a record) │ └──► DimensionAttributeValueSet (parent record — one per unique set) │ └──► DimensionAttributeValueSetItem (one row per dimension value in the set) │ ├── DimensionAttribute (which dimension: Department, CostCenter, etc.) └── DimensionAttributeValue (the actual value: 007, Marketing, etc.) Ledger Dimension (LedgerDimension field on a transaction line) │ └──► DimensionAttributeValueCombination (full account+dimension combo — denormalized) │ └──► DimensionAttributeValueGroup │ └──► DimensionAttributeLevelValue (one row per segment value)

Why dimension data is immutable

Dimension records are never updated or deleted — only inserted. When you change a dimension value on a record, the framework creates a new DimensionAttributeValueSet record and links the record to the new set. The old set remains in the database for historic transactions. This is why you must never write to these tables directly — only through the facade APIs.

⚠️ Never assign a string or hardcoded RecId to a dimension field

Assigning a raw string or a hardcoded numeric value to LedgerJournalTrans.LedgerDimension or any DefaultDimension field compiles and runs without error — but produces either a zero RecId (unresolved account) or a stale reference at posting time. Always use the facade APIs to build dimension values.

Part 1 — Reading dimension values from existing records

Read a specific dimension value from a default dimension

The most common read scenario: given a record's DefaultDimension RecId, extract the value of a specific dimension attribute (e.g. Cost Centre, Department).

/// Read a single dimension attribute value from a DefaultDimension set
public static DimensionValue getDimensionValueFromDefaultDim(
    DimensionDefault    _defaultDimension,
    Name                _dimensionAttributeName)
{
    DimensionAttributeValueSetItemView  dimView;
    DimensionAttribute                  dimAttribute;

    select firstonly DisplayValue from dimView
        where dimView.DimensionAttributeValueSet == _defaultDimension
        exists join dimAttribute
            where dimAttribute.RecId == dimView.DimensionAttribute
               && dimAttribute.Name  == _dimensionAttributeName;

    return dimView.DisplayValue;
}

// Usage
VendTable vendTable = VendTable::find('US-001');
DimensionValue costCentre = getDimensionValueFromDefaultDim(
    vendTable.DefaultDimension,
    'CostCenter');

info(strFmt("Cost Centre on vendor US-001: %1", costCentre));

✅ Use DimensionAttributeValueSetItemView for reads — not the base tables directly

DimensionAttributeValueSetItemView is a view that joins DimensionAttributeValueSetItem with DimensionAttributeValue and exposes DisplayValue directly. Using this view is both cleaner and safer than joining the underlying tables manually.

Read dimensions using SQL (for debugging or reporting)

Useful for quickly inspecting dimension values in a sandbox environment.

SELECT DA.NAME, V.DISPLAYVALUE FROM DIMENSIONATTRIBUTEVALUESETITEMVIEW V JOIN DIMENSIONATTRIBUTE DA ON DA.RECID = V.DIMENSIONATTRIBUTE WHERE V.DIMENSIONATTRIBUTEVALUESET = -- paste the DefaultDimension RecId here

Get display value from a ledger dimension

To read the full formatted account string (e.g. 110180-007-023) from a LedgerDimension RecId:

// Get the display string from a ledger dimension RecId
LedgerDimensionAccount ledgerDim = ledgerJournalTrans.LedgerDimension;

str displayValue = LedgerDimensionFacade::getDisplayValueForLedgerDimension(ledgerDim);
// Returns: "110180-007-023" (main account + all dimension segments)

// Get just the main account Id
MainAccountNum mainAccountNum = LedgerDimensionFacade::getMainAccountIdFromLedgerDimension(ledgerDim);
// Returns: "110180"

// Get the main account RecId (for joins)
RecId mainAccountRecId = LedgerDimensionFacade::getMainAccountRecIdFromLedgerDimension(ledgerDim);

// Extract the default dimension set from a ledger dimension
DimensionDefault dimDefault = LedgerDimensionFacade::getDefaultDimensionFromLedgerDimension(ledgerDim);

Part 2 — Writing a single dimension value to a default dimension

This is the most common write scenario: you have an existing record and you need to set or update one dimension value (e.g. stamp a cost centre on a custom record when a business event triggers).

The correct class is DimensionAttributeValueSetStorage. Never write to DimensionAttributeValueSetItem or related tables directly.

/// Set a single dimension attribute value on an existing DefaultDimension set.
/// Returns the new DefaultDimension RecId.
public static DimensionDefault setDefaultDimensionValue(
    DimensionDefault    _existingDefaultDim,
    Name                _dimensionAttributeName,
    DimensionValue      _value)
{
    DimensionAttributeValueSetStorage   dimStorage;
    DimensionAttribute                  dimAttribute;
    DimensionAttributeValue             dimAttributeValue;

    // Load the existing dimension set into the storage class
    dimStorage = DimensionAttributeValueSetStorage::find(_existingDefaultDim);

    // Find the dimension attribute by name
    dimAttribute = DimensionAttribute::findByName(_dimensionAttributeName);

    if (!dimAttribute)
    {
        throw error(strFmt("Dimension attribute '%1' not found.", _dimensionAttributeName));
    }

    if (_value)
    {
        // Find or create the dimension attribute value
        dimAttributeValue = DimensionAttributeValue::findByDimensionAttributeAndValue(
            dimAttribute,
            _value,
            false,   // do not create if not found
            true);   // throw error if not found

        dimStorage.addItem(dimAttributeValue);
    }
    else
    {
        // Clear this dimension from the set
        dimStorage.removeDimensionAttribute(dimAttribute.RecId);
    }

    // Save returns a new RecId (immutable — always a new set record)
    return dimStorage.save();
}

// Usage — set CostCenter = '007' on a vendor record
VendTable vendTable;
select forUpdate vendTable
    where vendTable.AccountNum == 'US-001';

ttsBegin;
vendTable.DefaultDimension = setDefaultDimensionValue(
    vendTable.DefaultDimension,
    'CostCenter',
    '007');
vendTable.doUpdate();
ttsCommit;

⚠️ Always use doUpdate() when stamping dimensions mid-process

Calling update() on a record inside a posting or integration pipeline triggers validateWrite() and modifiedField() which can cascade into unwanted side effects. Use doUpdate() when you are writing dimension values as a targeted field update, not as a full record edit.

⚠️ DimensionAttributeValue must already exist in the system

The DimensionAttributeValue::findByDimensionAttributeAndValue() call with false, true parameters looks up an existing value and throws if it is not found. If you are writing a dimension value that may not exist yet (e.g. in a data migration), use true, false to create it, but be aware this bypasses any setup validation on the dimension.

Part 3 — Merging default dimensions

Merging is how D365 F&O builds dimension sets when a document header copies to a line, or when an item's dimensions are combined with a purchase order line's dimensions. Understanding merge precedence is essential for building integrations that produce the same result as the standard application.

Merge precedence rule

When two default dimension sets are merged, the first parameter wins — values from _value1 take precedence over _value2. Empty values in _value1 are filled in from _value2.

DimensionSource 1 (higher precedence)Source 2 (lower precedence)Merged result
BusinessUnitOU_001OU_002OU_001 — Source 1 wins
CostCenter(empty)007007 — Source 1 empty, filled from Source 2
Department023(empty)023 — Source 1 wins
ItemGroup(empty)(empty)(empty)

// Verified API signature from Microsoft Learn
// public static DimensionDefault serviceMergeDefaultDimensions(
//     DimensionDefault _value1,   ← higher precedence
//     DimensionDefault _value2,   ← lower precedence (fills empty slots)
//     DimensionDefault _value3 = 0,
//     DimensionDefault _value4 = 0)

// Example: merge header dimensions with line dimensions
// Header has higher precedence — same as standard PurchLine logic
DimensionDefault mergedDim = DimensionDefaultFacade::serviceMergeDefaultDimensions(
    purchLine.DefaultDimension,     // line (higher — user can override)
    purchTable.DefaultDimension);   // header (lower — fills gaps)

// If you need to merge more than 4 sources, chain the calls:
DimensionDefault step1 = DimensionDefaultFacade::serviceMergeDefaultDimensions(
    source1, source2);

DimensionDefault step2 = DimensionDefaultFacade::serviceMergeDefaultDimensions(
    step1, source3);

DimensionDefault finalDim = DimensionDefaultFacade::serviceMergeDefaultDimensions(
    step2, source4);

Replace a single attribute value in a merged set

Use serviceReplaceAttributeValue() when you want to overwrite exactly one dimension in an existing set without touching the others — for example, overriding the transaction type dimension from a posting parameter.


// public static DimensionDefault serviceReplaceAttributeValue(
//     DimensionDefault _target,
//     DimensionDefault _source,
//     RecId            _dimensionAttributeId)

// Replace the Department dimension in the target set with the value from a source set
DimensionAttribute deptAttribute = DimensionAttribute::findByName('Department');

DimensionDefault updatedDim = DimensionDefaultFacade::serviceReplaceAttributeValue(
    targetDefaultDimension,     // dimension set to update
    sourceDepartmentDimension,  // dimension set containing the new value
    deptAttribute.RecId);       // which attribute to replace

✅ Use serviceMergeValidDefaultDimensions when posting validation matters

serviceMergeDefaultDimensions() merges without validating against the current ledger's account structures. serviceMergeValidDefaultDimensions() performs the same merge but filters out values that are not valid for the current company's ledger. Use the validated version when building the dimensions that will be posted to the GL — this prevents the "dimension value is not valid for this account structure" error at posting time.

Part 4 — Building ledger dimensions from scratch

A ledger dimension is a main account combined with dimension values, constrained by the company's account structure. You build one from two ingredients: a default account (main account) and a default dimension set.

API selection guide

ScenarioAPI to use
Build ledger dim from a main account + default dimensionsLedgerDimensionFacade::serviceCreateLedgerDimension()
Build a budget or planning account instead of a GL accountLedgerDimensionFacade::serviceCreateLedgerDimensionForType()
Copy from default dim first, then overlay ledger dim valuesLedgerDimensionFacade::serviceCreateLedgerDimForDefaultDim()
Combine two existing ledger dimensionsLedgerDimensionFacade::serviceMergeLedgerDimensions()
Combine up to 5 ledger dimensions, keeping first main accountLedgerDimensionFacade::serviceLedgerDimensionFromLedgerDims()
Copy a posted ledger dim to a new unposted document (account structure safe)LedgerDimensionFacade::serviceCreateLedgerDimFromLedgerDim()
Build from main account Id string directlyLedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId()

Pattern 1 — Build a ledger dimension from a main account + default dimensions


// Step 1: Get the default account RecId from a main account number
// LedgerDefaultAccountHelper builds a minimal ledger dimension with no extra dimensions
LedgerDimensionAccount defaultAccount =
    LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId('110180');

// Step 2: Get or build your default dimension set
// Here we take it from a vendor record
VendTable vendTable = VendTable::find('US-001');
DimensionDefault vendorDim = vendTable.DefaultDimension;

// Step 3: Create the ledger dimension — main account from defaultAccount,
// dimension values filled from vendorDim
// API: LedgerDimensionFacade::serviceCreateLedgerDimension(
//     RecId            _ledgerDimensionId,   ← source of main account
//     DimensionDefault _dimensionDefault1,
//     DimensionDefault _dimensionDefault2 = 0,
//     DimensionDefault _dimensionDefault3 = 0)

LedgerDimensionAccount ledgerDim =
    LedgerDimensionFacade::serviceCreateLedgerDimension(
        defaultAccount,
        vendorDim);

// Use on a journal line
ledgerJournalTrans.LedgerDimension = ledgerDim;

Pattern 2 — Build a ledger dimension with a custom dimension override

This is the pattern when you need to combine the main account from a posting profile with dimension values from a transaction record, and override one specific dimension from a parameter.


public static LedgerDimensionAccount buildLedgerDimWithOverride(
    MainAccountNum      _mainAccount,
    DimensionDefault    _transactionDimensions,
    Name                _overrideDimName,
    DimensionValue      _overrideDimValue)
{
    LedgerDimensionAccount  defaultAccount;
    DimensionDefault        overrideDim;
    DimensionDefault        mergedDim;

    // Build the base account from main account number
    defaultAccount = LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId(_mainAccount);

    // Build a temporary default dimension set with just the override value
    DimensionAttributeValueSetStorage dimStorage = DimensionAttributeValueSetStorage::find(0);

    DimensionAttribute dimAttribute = DimensionAttribute::findByName(_overrideDimName);
    DimensionAttributeValue dimValue = DimensionAttributeValue::findByDimensionAttributeAndValue(
        dimAttribute, _overrideDimValue, false, true);

    dimStorage.addItem(dimValue);
    overrideDim = dimStorage.save();

    // Merge: override has higher precedence than transaction dimensions
    mergedDim = DimensionDefaultFacade::serviceMergeDefaultDimensions(
        overrideDim,                // higher precedence — our override wins
        _transactionDimensions);    // lower precedence — fills remaining gaps

    // Build the final ledger dimension
    return LedgerDimensionFacade::serviceCreateLedgerDimension(
        defaultAccount,
        mergedDim);
}

// Usage: post to account 618900 using the PO line dimensions,
// but override the Department to '099' (a custom posting rule)
LedgerDimensionAccount finalLedgerDim = buildLedgerDimWithOverride(
    '618900',
    purchLine.DefaultDimension,
    'Department',
    '099');

ledgerJournalTrans.LedgerDimension = finalLedgerDim;

Pattern 3 — Copy a ledger dimension from a posted transaction to a new document

When reversing or correcting a posted transaction, never copy the LedgerDimension RecId directly. Account structures may have changed since the original post. Use serviceCreateLedgerDimFromLedgerDim() to rebuild the combination against the current structure.


// WRONG — copies the RecId directly, may fail posting validation
// if account structure has changed since original post
newJournalTrans.LedgerDimension = originalJournalTrans.LedgerDimension;

// CORRECT — rebuilds the combination against the current account structure
newJournalTrans.LedgerDimension =
    LedgerDimensionFacade::serviceCreateLedgerDimFromLedgerDim(
        originalJournalTrans.LedgerDimension);

Part 5 — Replacing a dimension value on an existing ledger dimension

A common integration requirement: given an existing LedgerDimension on a journal line, replace one dimension value without changing the main account or any other dimension. For example, replacing the Business Unit from a transaction type parameter at posting time.


public static LedgerDimensionAccount replaceDimOnLedgerDim(
    LedgerDimensionAccount  _existingLedgerDim,
    Name                    _dimensionAttributeName,
    DimensionValue          _newValue)
{
    // Step 1: Extract the main account and default dimensions from the existing ledger dim
    RecId            mainAccountRecId = LedgerDimensionFacade::getMainAccountRecIdFromLedgerDimension(_existingLedgerDim);
    DimensionDefault existingDimDefault = LedgerDimensionFacade::getDefaultDimensionFromLedgerDimension(_existingLedgerDim);

    // Step 2: Build a single-value default dim with the new value
    DimensionAttribute dimAttribute = DimensionAttribute::findByName(_dimensionAttributeName);
    DimensionAttributeValue dimValue = DimensionAttributeValue::findByDimensionAttributeAndValue(
        dimAttribute, _newValue, false, true);

    DimensionAttributeValueSetStorage dimStorage = DimensionAttributeValueSetStorage::find(0);
    dimStorage.addItem(dimValue);
    DimensionDefault newSingleDim = dimStorage.save();

    // Step 3: Replace the specific attribute in the existing set
    DimensionDefault updatedDimDefault = DimensionDefaultFacade::serviceReplaceAttributeValue(
        existingDimDefault,     // target
        newSingleDim,           // source of the new value
        dimAttribute.RecId);    // which attribute to replace

    // Step 4: Rebuild the ledger dimension with updated dimensions
    LedgerDimensionAccount defaultAccount =
        LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountRecId(mainAccountRecId);

    return LedgerDimensionFacade::serviceCreateLedgerDimension(
        defaultAccount,
        updatedDimDefault);
}

// Usage: replace Business Unit on an existing journal line ledger dimension
ledgerJournalTrans.LedgerDimension = replaceDimOnLedgerDim(
    ledgerJournalTrans.LedgerDimension,
    'BusinessUnit',
    'OU_004');

Part 6 — A reusable dimension helper class

Rather than repeating the DimensionAttribute::findByName() call everywhere, a centralised helper class that names each dimension attribute as a static method makes your code cleaner, enables Find All References in Visual Studio, and avoids magic strings scattered across the codebase.


/// 
/// Central helper for financial dimension operations.
/// Add a static method for each dimension attribute used in your customisation.
/// 
public class CSTDimensionHelper
{
    // -------------------------------------------------------
    // Dimension attribute name constants
    // -------------------------------------------------------

    private static Name BusinessUnit()   { return 'BusinessUnit'; }
    private static Name CostCenter()     { return 'CostCenter'; }
    private static Name Department()     { return 'Department'; }
    private static Name Project()        { return 'Project'; }

    // Add your custom dimensions here — one method per dimension
    // private static Name TransactionType() { return 'TransactionType'; }

    // -------------------------------------------------------
    // Read a value from a default dimension set
    // -------------------------------------------------------

    public static DimensionValue getValueFromDefaultDim(
        DimensionDefault    _defaultDimension,
        Name                _dimensionName)
    {
        DimensionAttributeValueSetItemView  dimView;
        DimensionAttribute                  dimAttribute;

        select firstonly DisplayValue from dimView
            where dimView.DimensionAttributeValueSet == _defaultDimension
            exists join dimAttribute
                where dimAttribute.RecId == dimView.DimensionAttribute
                   && dimAttribute.Name  == _dimensionName;

        return dimView.DisplayValue;
    }

    // -------------------------------------------------------
    // Set a single value on a default dimension set
    // Returns new DefaultDimension RecId
    // -------------------------------------------------------

    public static DimensionDefault setValueOnDefaultDim(
        DimensionDefault    _existingDim,
        Name                _dimensionName,
        DimensionValue      _value)
    {
        DimensionAttributeValueSetStorage   dimStorage;
        DimensionAttribute                  dimAttribute;
        DimensionAttributeValue             dimAttributeValue;

        dimStorage    = DimensionAttributeValueSetStorage::find(_existingDim);
        dimAttribute  = DimensionAttribute::findByName(_dimensionName);

        if (_value)
        {
            dimAttributeValue = DimensionAttributeValue::findByDimensionAttributeAndValue(
                dimAttribute, _value, false, true);
            dimStorage.addItem(dimAttributeValue);
        }
        else
        {
            dimStorage.removeDimensionAttribute(dimAttribute.RecId);
        }

        return dimStorage.save();
    }

    // -------------------------------------------------------
    // Convenience accessors — named per dimension
    // -------------------------------------------------------

    public static DimensionValue getCostCenter(DimensionDefault _defaultDim)
    {
        return CSTDimensionHelper::getValueFromDefaultDim(_defaultDim, CSTDimensionHelper::CostCenter());
    }

    public static DimensionDefault setCostCenter(DimensionDefault _defaultDim, DimensionValue _value)
    {
        return CSTDimensionHelper::setValueOnDefaultDim(_defaultDim, CSTDimensionHelper::CostCenter(), _value);
    }

    public static DimensionValue getDepartment(DimensionDefault _defaultDim)
    {
        return CSTDimensionHelper::getValueFromDefaultDim(_defaultDim, CSTDimensionHelper::Department());
    }

    public static DimensionDefault setDepartment(DimensionDefault _defaultDim, DimensionValue _value)
    {
        return CSTDimensionHelper::setValueOnDefaultDim(_defaultDim, CSTDimensionHelper::Department(), _value);
    }

    // -------------------------------------------------------
    // Build a ledger dimension from a main account + default dim
    // -------------------------------------------------------

    public static LedgerDimensionAccount buildLedgerDimension(
        MainAccountNum      _mainAccountNum,
        DimensionDefault    _defaultDimension)
    {
        LedgerDimensionAccount defaultAccount =
            LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId(_mainAccountNum);

        return LedgerDimensionFacade::serviceCreateLedgerDimension(
            defaultAccount,
            _defaultDimension);
    }
}

// Usage — clean, readable, refactor-safe
VendTable vendTable = VendTable::find('US-001');

str costCentre  = CSTDimensionHelper::getCostCenter(vendTable.DefaultDimension);
str department  = CSTDimensionHelper::getDepartment(vendTable.DefaultDimension);

// Set a new cost centre
ttsBegin;
vendTable.reread();
vendTable.DefaultDimension = CSTDimensionHelper::setCostCenter(
    vendTable.DefaultDimension, '009');
vendTable.doUpdate();
ttsCommit;

// Build a ledger dimension for a journal line
ledgerJournalTrans.LedgerDimension = CSTDimensionHelper::buildLedgerDimension(
    '110180',
    custTable.DefaultDimension);

Common pitfalls summary

⚠️ 1. Assigning a string to LedgerDimension — the #1 bug

LedgerJournalTrans.LedgerDimension is a RecId, not a string. Assigning an account number string assigns 0 to a numeric field — the compiler does not catch this. The journal line posts with an unresolved account. Always use LedgerDefaultAccountHelper::getDefaultAccountFromMainAccountId() to build the RecId.

⚠️ 2. Copying a LedgerDimension RecId to a new document directly

If the account structure configuration has changed since the original document was posted, copying the RecId directly may produce a ledger dimension that fails posting validation. Always use LedgerDimensionFacade::serviceCreateLedgerDimFromLedgerDim() when copying from a historic document to a new one.

⚠️ 3. Not understanding merge precedence direction

In serviceMergeDefaultDimensions(_value1, _value2), _value1 has higher precedence. Many developers assume the second parameter wins. The rule is: _value1 values always win; _value2 only fills in empty slots. Getting this backwards causes dimensions from the wrong source to appear on posted transactions.

⚠️ 4. Using serviceMergeDefaultDimensions when serviceReplaceAttributeValue is needed

If you want to overwrite one specific dimension in an existing set, use serviceReplaceAttributeValue(). Using serviceMergeDefaultDimensions() will also copy any other non-empty values from the source set, which is usually not what you want when overriding a single attribute.

⚠️ 5. Fixed dimensions on the main account overwrite everything at posting

If a main account has a dimension set as Fixed in the Main Accounts setup (General Ledger → Chart of Accounts → Main accounts → Legal entity overrides → Default dimensions), that value overwrites whatever your code sets at posting time — even if the user manually entered a different value. If dimension values are disappearing or changing after posting, check Fixed dimension configuration on the main account before looking at the code.


Conclusion :-

The Financial Dimension Framework has a steep initial learning curve because it hides significant complexity behind what looks like a simple field on a record. Once you understand that every dimension field is a RecId into an immutable normalised set, and that there are specific facade APIs for every operation — reading, writing, merging, and building ledger dimensions — the framework becomes predictable and systematic.

The four classes you need to know for 95% of dimension work are: DimensionAttributeValueSetStorage for reading and writing default dimensions, DimensionDefaultFacade for merging and replacing values, LedgerDimensionFacade for all ledger dimension operations, and LedgerDefaultAccountHelper for building default accounts from main account numbers.

Building a centralised helper class with named methods for each dimension attribute in your project — as shown in Part 6 — removes magic strings from the codebase, makes dimension logic refactorable, and produces code that any developer on the project can read and maintain.


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

Monday, July 6, 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 !!!!

Financial Dimension Framework in D365 F&O — Complete X++ Developer Guide

The Financial Dimension Framework is one of the most misunderstood parts of D365 F&O development. Most developers encounter it for the f...