Friday, July 3, 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 !!!!

Monday, November 24, 2025

How to create a computed column in a view through x++ in D365 F&O ?

Computed columns in D365 Finance and Operations (D365 F&O) are extremely useful when you want to add dynamic values in views, which are calculated at runtime using SQL. Instead of storing values in tables, computed columns help improve performance and reduce storage. 

When you have an AOT query where sometimes adding additional data source causes returning of duplicate records and then if this query is used in a view which is then being used in an inquiry form will result in displaying wrong data. In such scenarios Computed Columns in a view help us a lot. 

In this post, I will show you step-by-step how to create a computed column using X++ that gets column value from a table in D365 FO based on a value returned by a data source in an AOT query.

Below mentioned is a step by step process to achieve the same : - 

Step 1: Create a New View

  1. Go to AOT → Data Model → Views

  2. Right-click → New View

  3. Rename it: LedgerVoucherComputedView


Step 2: Add Data Sources

Add the desired table (e.g., VendPackingSlipJour) to the view.


Step 3: Add Fields

Add basic fields you want in the view like PackingSlipId, DataAreaId, DeliveryDate, etc.


Step 4: Add Computed Column

  1. Right-click View → New → Computed Column

  2. Rename it: VoucherNo


Step 5: Write X++ Logic for the Computed Column

Now we define the computation using X++ static method.

Create a static method for computed column in the View


public static str computePhysicalVoucher()
{
    DictView  myView = new DictView(tableNum(LedgerVoucherComputedView));
    str primaryDatasourcename = myView.query().dataSourceTable(tablenum(VendPackingSlipJour)).name();
str physicalvoucher = strFmt(@"SELECT TOP 1 SubledgerVoucher FROM GeneralJournalEntry WHERE GeneralJournalEntry.DocumentNumber = %1 and GeneralJournalEntry.SubledgerVoucherDataAreaId = %2 and GeneralJournalEntry.AccountingDate = %3", SysComputedColumn::returnField(tableStr(LedgerVoucherComputedView),primaryDatasourcename,fieldStr(VendPackingSlipJour,PackingSlipId)), SysComputedColumn::returnField(tableStr(LedgerVoucherComputedView),primaryDatasourcename,fieldStr(VendPackingSlipJour,DataAreaId)), SysComputedColumn::returnField(tableStr(LedgerVoucherComputedView),primaryDatasourcename,fieldStr(VendPackingSlipJour,DeliveryDate))); return physicalvoucher; }

Step 6: Attach the method to the computed column

  • Select your computed column (VoucherNo)
  • In Properties → View Method → set value to : computePhysicalVoucher

Conclusion

Computed columns in D365 F&O are powerful for real-time calculations without storing unnecessary data. Using X++, we can dynamically build SQL expressions and boost performance.


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

Thursday, November 20, 2025

How to upload a file in SFTP server through x++ in D365 F&O ?

 

When working with Dynamics 365 Finance and Operations (D365 F&O), integrations often require us to send or receive files from external systems. One of the most common ways to do this securely is through SFTP (Secure File Transfer Protocol).

Unfortunately, D365 F&O doesn’t provide a native SFTP client out of the box. Instead, we have a few options to achieve this:

  • Use Azure Blob Storage + Logic Apps / Azure Functions for file movement (Microsoft recommended approach).

  • Use third-party libraries like Renci.SshNet with .NET interop inside X++.

In this post, we’ll focus on the direct SFTP approach using X++ and .NET interop.


Prerequisites: -

Before jumping into code, make sure you have the following in place:

  1. Access to the SFTP server (host, port, username, password, and destination path).

  2. The Renci.SshNet.Async library deployed to your D365 F&O environment (uploaded via Visual Studio project reference).

  3. Proper permissions in your D365 F&O environment to run external .NET assemblies.


Now let us see step by step how to achieve this SFTP File upload functionality : - 

Step 1 - Create a C# Project for Class Library targeting to .Net Framework 4.7.2 (Very Important as D365 F&O also targets the same framework

Step 2 - Right click on the project and select Manage Nuget Packages. Go to Browse and install Renci.SshNet.Async package library



Step 3 - Create a class in C# Project and give it any name such as this case we have named it SFTPTransfer

Step 4 - Use the below code to create the functionality to upload your desired file to SFTP server : - 


using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Renci.SshNet;

namespace SFTPFileTransfer
{
    public class SFTPTransfer
    {
        public void TransferSFTPFile(string _host, string _username, string _password, System.IO.Stream _sourceFile,
        string _destinationPath, int _port, string _fileName)
        {
            List <AuthenticationMethod> methods;

            methods = new List<AuthenticationMethod>
            {
                new PasswordAuthenticationMethod(_username, _password)
            };

            try
            {
                var connectionInfo = new ConnectionInfo(_host, _port, _username, methods.ToArray());

                using (SftpClient sftpclient = new SftpClient(connectionInfo))
                {
                    sftpclient.Connect();

                    sftpclient.ChangeDirectory(_destinationPath.Trim());

                    _sourceFile.Position = 0;

                    sftpclient.BufferSize = 8 * 1024;

                    sftpclient.UploadFile(_sourceFile, _fileName);
                }
            }
            catch (WebException ex)
            {
            }
        }
    }
}


Step 5 - Build the class library project which should generate the DLL file. In our case DLL file name is SFTPFileTransfer.dll

Step 6 - Copy and paste the dll into the path - \AosService\PackagesLocalDirectory\yourmodelname\bin
 
Step 7 - Create a new Finance Operations project. In our case we have named it FinalSFTPExportTest and created two classes - SFTPUploadTest and VendAgingReportController_Extension (as we are testing SFTP file transfer for Vendor Aging Report in excel format) 

Step 8 - Use the below mentioned code in SFTPUploadTest class : - 

 
using SFTPFileTransfer;

public class SFTPUploadTest
{
    public void executeuploadtoSFTP(SrsReportRunController _controller)
    {
        str sftpServer   = "abc.sftp.com";
        int sftpPort     = 22;
        str sftpUser     = "xyz";
        str sftpPassword = "abcd12344";
        str sftpPath     = "/AB/Report/"; // remote directory

        str fileName = 'VendorAgingReport.xlsx';
        // Be careful with the fileName as few SFTP servers reject the file due to invalid characters in the name

        // --- Generate Report as Excel file ---
    
        SRSPrintDestinationSettings printSettings;
        SRSReportRunService         srsReportRunService;
        Map                         reportParametersMap;
        Microsoft.Dynamics.AX.Framework.Reporting.Shared.ReportingService.ParameterValue[] parameterValueArray;
        SRSProxy                    srsProxy;
        System.Byte[]               reportBytes;

        // Setup report
        _controller.parmReportName(ssrsReportStr(VendAgingReport, DesignWithNoDetailAndNoTransactionCur)); // Vendor Aging Report
        _controller.parmShowDialog(false);
        _controller.parmExecutionMode(SysOperationExecutionMode::Synchronous);

        // Print settings
        printSettings = _controller.parmReportContract().parmPrintSettings();
        printSettings.printMediumType(SRSPrintMediumType::File);
        printSettings.fileFormat(SRSReportFileFormat::Excel);

        // Run report service
        srsReportRunService = new SRSReportRunService();
        srsReportRunService.getReportDataContract(_controller.parmReportContract().parmReportName());
        srsReportRunService.preRunReport(_controller.parmReportContract());

        reportParametersMap = srsReportRunService.createParamMapFromContract(_controller.parmReportContract());
        parameterValueArray = SrsReportRunUtil::getParameterValueArray(reportParametersMap);

        _controller.parmReportContract().parmReportExecutionInfo(new SRSReportExecutionInfo());
        _controller.parmReportContract().parmReportServerConfig(SRSConfiguration::getDefaultServerConfiguration());

        srsProxy = SRSProxy::constructWithConfiguration(_controller.parmReportContract().parmReportServerConfig());

        // Render report to byte array (Excel)
        reportBytes = srsProxy.renderReportToByteArray(_controller.parmReportContract().parmReportPath(),
                                                    parameterValueArray,
                                                    printSettings.fileFormat(),
                                                    printSettings.deviceinfo());

        if (!reportBytes || reportBytes.get_Length() == 0)
        {
            throw error("Vendor aging report rendering failed.");
        }
        System.IO.Stream objstream = new System.IO.MemoryStream(reportBytes);

        // --- Upload to SFTP ---
        SFTPTransfer transfer = new SFTPTransfer();

        transfer.TransferSFTPFile(sftpServer,sftpUser,sftpPassword,objstream,sftpPath,sftpPort,fileName);

    }

}

Step 9 - Use the below mentioned code to call  executeuploadtoSFTP method inside VendAgingReportController_Extension class so that every time the report runs the report output will be exported in excel format and will be sent to SFTP server : - 

[ExtensionOf(classstr(VendAgingReportController))]
final class VendAgingReportController_Extension
{
    protected void preRunModifyContract()
    {
        next preRunModifyContract();

        SFTPUploadTest objsftpupload = new SFTPUploadTest();

        objsftpupload.executeuploadtoSFTP(this);

    }

}


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

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 t...