5 min readRishi

Extending Data Entities in D365 Finance & Operations Without Breaking Upgrades

Data entities are how almost everything moves in and out of Dynamics 365 Finance & Operations: the Data Management Framework (DMF), OData, dual-write, and Power Platform virtual entities all sit on top of them. Sooner or later you need a standard entity to carry one extra field, expose a related table, or enforce custom validation. Do it carelessly and your next One Version update overwrites the change — or worse, the entity silently stops importing. Here is how to extend data entities so they survive upgrades.

Extend, don't overlayer

Overlayering is gone. Every change to a standard entity is an extension: you create an extension element in your own model and the compiler merges it at build time. The rule of thumb:

  • Adding fields, ranges, or relations to a standard entity → extension.
  • Heavy reshaping (different staging, new natural key, denormalized joins) → a brand-new entity that you own outright.

A new entity is fully yours and never conflicts with Microsoft's changes. Extensions are lighter but constrained: you can add, you cannot remove or re-type what Microsoft ships.

Adding a field to a standard entity

Say you added PaymentHoldReason to VendTable and you need it on VendVendorV2Entity so integrations can read and write it. Three steps:

  1. Create an extension of the entity and drag the new field into the entity's data source so it is available for mapping.
  2. Add the entity field and map it to the data source column.
  3. Rebuild and regenerate the staging table.

The mapping is the part people forget. An entity field with no data source mapping compiles fine and silently returns null on every import.

// Run business logic as the entity writes back to the table. Put it on the
// ENTITY (not the table) so DMF and OData both honor it.
[ExtensionOf(tableStr(VendVendorV2Entity))]
final class VendVendorV2Entity_Extension
{
    public void mapEntityToDataSource(DataEntityRuntimeContext _context)
    {
        next mapEntityToDataSource(_context);

        VendTable vendTable = _context.getDatabaseEntityDataSourceObject(
            tableNum(VendTable)) as VendTable;

        if (this.PaymentHoldReason && !vendTable.PaymentStop)
        {
            vendTable.PaymentStop = NoYes::Yes;
        }
    }
}

Computed and virtual fields

Not every field maps one-to-one. Two escape hatches:

  • Virtual (unmapped) fields are filled in code via postLoad(). Use them for values assembled at read time — a formatted address, a derived status.
  • Computed columns push a SQL expression into the view that backs the entity. The database evaluates them, so they filter and sort efficiently, but the expression must be expressible through SysComputedColumn server methods.
private static server str compute formattedVendorName()
{
    DataEntityName dataEntity = identifierStr(VendVendorV2Entity);
    str account = SysComputedColumn::returnField(dataEntity,
        dataEntityDataSourceStr(VendVendorV2Entity, VendTable),
        fieldStr(VendTable, AccountNum));
    str name = SysComputedColumn::returnField(dataEntity,
        dataEntityDataSourceStr(VendVendorV2Entity, VendTable),
        fieldStr(VendTable, VendorName));

    return SysComputedColumn::stringConcat(account,
        SysComputedColumn::literal(' - '), name);
}

Reach for postLoad() only when the database cannot do the work. Every virtual field is computed row by row in X++ after the result set returns, which destroys performance on bulk reads.

Validation that actually fires

validateField and validateWrite on the entity run for OData and DMF writes. Put cross-field rules here, not on the table, or imports bypass them entirely.

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

    if (this.PaymentHoldReason && !this.PaymentStop)
    {
        ret = checkFailed("A hold reason requires payment stop to be set.") && ret;
    }
    return ret;
}

The staging-table trap

DMF imports land in a staging table first, then copy to the target. When you add a field through an extension, the staging table does not grow automatically — you must regenerate it (right-click the entity, Generate staging table, or rebuild with that option set). Skip this and the import maps your field to nothing while reporting success.

SymptomLikely cause
New field always null after importField unmapped, or staging table not regenerated
Works in dev, fails in UATStaging table out of sync; redeploy the package
OData write ignores validationLogic on the table instead of the entity
Exports slow after adding fieldsToo many virtual fields doing postLoad work

Cross-company and keys

Two more things bite teams:

  • DataAreaId: public entities are cross-company by default. If your extension joins a company-specific table, set the entity's company behaviour explicitly or every caller has to pass cross-company=true.
  • Entity keys: the natural key (for example AccountNum) is what upsert matches on. If a new field belongs to identity, you almost always need a new entity — you cannot safely re-key Microsoft's.

Test like an integration, not a user

Before you ship: import a file through DMF and confirm the field round-trips; GET and PATCH the entity through OData with a REST client; and if the entity feeds dual-write or virtual entities, confirm change tracking still initializes after your change. An entity that passes the form but fails the API is the most common — and most expensive — way to learn this lesson in production.

Keep reading

Newsletter

New posts, straight to your inbox

One email per post. No spam, no tracking pixels, unsubscribe anytime.

Comments

No comments yet. Be the first.