·9 min read·Rishi

Virtual Entities in D365 F&O: Exposing External Data Without the ETL Tax

Virtual Entities in D365 F&O: Exposing External Data Without the ETL Tax

Your procurement team needs supplier quality ratings in D365 F&O. The ratings live in a third-party supplier management system with a REST API. The traditional approach: build an integration that pulls the data nightly, stages it in a custom table, maps the fields, handles errors, and runs a batch job on a schedule. Two weeks of development, a recurring batch job to monitor, and data that is always at least a day stale.

Or you build a virtual entity. The data appears in F&O as if it were a native table — users can see it on forms, filter it, even join it in queries — but it is never stored in F&O. Every read goes directly to the source API in real time. No import job. No staging table. No stale data.

Virtual entities are one of the most underused features in D365 F&O. Here is how to build one.

What Virtual Entities Are

A virtual entity is an X++ data entity that does not have a physical backing table in F&O. Instead, it implements a data provider class that fetches data from an external source at query time. To the rest of the system — forms, queries, reports, other code — it looks like a normal table. But there is no data at rest in F&O.

What they support:

  • Read operations (select, filtering, sorting, paging)
  • Display on forms and workspaces
  • Joining with physical tables in queries (with caveats)
  • OData exposure for external consumption

What they do not support:

  • Write operations (insert, update, delete) — they are read-only
  • Full-text search
  • Being a target for data management framework import/export
  • Complex aggregations at the database level

When to Use Virtual Entities vs. Alternatives

ScenarioVirtual EntityData ImportDual-Write
Data freshnessReal-timeAs stale as your scheduleNear-real-time
Data volume for readsLow-moderate (< 10K rows typical)Any volumeAny volume
Write-back neededNoNo (one direction)Yes
Storage cost in F&OZeroProportional to dataProportional to data
Query performanceDepends on external APIFast (local DB)Fast (local DB)
Offline resilienceFails if API is downData is localData is local

The decision point is simple: If you need to display external data in F&O and the volume is manageable (hundreds to low thousands of records per query), virtual entities eliminate an entire integration layer. If you need to do heavy processing, reporting, or joins across millions of rows, import the data.

Real Use Case: Supplier Quality Ratings

We will build a virtual entity that exposes supplier quality ratings from an external API. The API returns data like this:

GET https://supplier-api.example.com/api/ratings?vendorCode=V-1001

{
  "ratings": [
    {
      "vendorCode": "V-1001",
      "category": "On-Time Delivery",
      "score": 94.2,
      "evaluationDate": "2026-03-15",
      "status": "Approved"
    },
    {
      "vendorCode": "V-1001",
      "category": "Quality Compliance",
      "score": 88.7,
      "evaluationDate": "2026-03-15",
      "status": "Conditional"
    }
  ]
}

Step 1: Create the Data Entity

First, define the virtual entity in X++. This is a data entity without a physical data source.

[DataEntityViewAttribute]
public class SupplierQualityRatingEntity extends common
{
    // Fields matching the external data structure
    public str 20  VendorCode;
    public str 50  Category;
    public real    Score;
    public date    EvaluationDate;
    public str 20  Status;
}

In the entity's metadata properties:

PropertyValue
Is VirtualYes
Data Provider ClassSupplierQualityRatingProvider
Primary KeyVendorCode, Category
Public Entity NameSupplierQualityRatings

Setting Is Virtual = Yes tells the runtime that this entity has no physical storage. All data access is routed to the data provider class.

Step 2: Build the Data Provider

The data provider is where the real work happens. It implements the VirtualEntityDataProvider interface and handles query execution.

class SupplierQualityRatingProvider implements VirtualEntityDataProvider
{
    public VirtualEntityDataProviderResponse executeQuery(
        VirtualEntityDataProviderRequest _request)
    {
        VirtualEntityDataProviderResponse response =
            new VirtualEntityDataProviderResponse();

        // Extract filter criteria from the incoming query
        str vendorCode = this.extractVendorFilter(
            _request.getQueryExpression());

        if (!vendorCode)
        {
            // Without a vendor filter, return empty —
            // do not fetch all ratings from the API
            return response;
        }

        // Call the external API
        str jsonResponse = this.callSupplierApi(vendorCode);

        // Parse and map the results
        this.mapResults(jsonResponse, response);

        return response;
    }

    private str callSupplierApi(str _vendorCode)
    {
        System.Net.Http.HttpClient httpClient =
            new System.Net.Http.HttpClient();

        str url = strFmt(
            'https://supplier-api.example.com/api/ratings?vendorCode=%1',
            _vendorCode);

        // Add authentication header
        str apiKey = this.getApiKeyFromKeyVault();
        httpClient.DefaultRequestHeaders.Add('X-API-Key', apiKey);

        System.Net.Http.HttpResponseMessage responseMsg =
            httpClient.GetAsync(url).Result;

        if (!responseMsg.IsSuccessStatusCode)
        {
            throw error(strFmt(
                "Supplier API returned %1",
                responseMsg.StatusCode));
        }

        return responseMsg.Content.ReadAsStringAsync().Result;
    }

    private str getApiKeyFromKeyVault()
    {
        // Retrieve API key from Azure Key Vault —
        // never hardcode credentials
        KeyVaultCertificateHelper helper =
            new KeyVaultCertificateHelper();
        return helper.getManualSecretValue('SupplierApiKey');
    }

    private void mapResults(
        str _json,
        VirtualEntityDataProviderResponse _response)
    {
        // Parse JSON using Newtonsoft
        Newtonsoft.Json.Linq.JObject root =
            Newtonsoft.Json.Linq.JObject.Parse(_json);
        Newtonsoft.Json.Linq.JArray ratings =
            root.GetValue('ratings');

        var enumerator = ratings.GetEnumerator();
        while (enumerator.MoveNext())
        {
            Newtonsoft.Json.Linq.JToken item = enumerator.Current;

            SupplierQualityRatingEntity entity;
            entity.VendorCode =
                item.Value<System.String>('vendorCode');
            entity.Category =
                item.Value<System.String>('category');
            entity.Score =
                item.Value<System.Double>('score');
            entity.EvaluationDate =
                item.Value<System.DateTime>('evaluationDate');
            entity.Status =
                item.Value<System.String>('status');

            _response.addEntity(entity);
        }
    }

    private str extractVendorFilter(
        QueryExpression _queryExpression)
    {
        // Parse the query expression to find
        // the VendorCode filter value
        var filters = _queryExpression.getFilters();
        // ... extraction logic
        return vendorCode;
    }
}

Critical Design Decisions

  1. Require a filter. Without a vendor code filter, the provider returns empty. This prevents users from accidentally loading every supplier rating in the system with an unfiltered query
  2. Key Vault for credentials. The API key comes from Azure Key Vault, not a configuration table or hardcoded string
  3. Error handling. A failed API call throws an error that surfaces in the UI, not a silent empty result

Step 3: Add It to a Form

Create a form part or add a grid to an existing vendor form:

// On the VendTable form, add a data source
// linked to SupplierQualityRatingEntity
// with a link to VendTable on VendorCode = VendAccount

// The form grid columns:
//   Category | Score | Status | EvaluationDate

When a user opens a vendor record, the form loads the virtual entity data source, which calls the provider, which calls the external API, which returns the ratings. To the user, it looks exactly like a related table.

Performance: The Make-or-Break Factor

Virtual entities live and die on performance. The data is fetched on every query — there is no caching by default. Here is how to keep them fast:

Caching Strategy

Implement a response cache to avoid hitting the API on every form refresh:

// Use SysGlobalObjectCache for in-memory caching
SysGlobalObjectCache cache = new SysGlobalObjectCache();
str cacheKey = strFmt('SupplierRating_%1', vendorCode);
container cachedData = cache.find(cacheKey);

if (cachedData != conNull())
{
    // Return cached data if less than 5 minutes old
    utcDateTime cacheTime = conPeek(cachedData, 1);
    if (DateTimeUtil::getDifference(
        DateTimeUtil::utcNow(), cacheTime) < 300)
    {
        return this.deserializeFromCache(cachedData);
    }
}

// Cache miss — call the API
str jsonResponse = this.callSupplierApi(vendorCode);
cache.insert(cacheKey, [DateTimeUtil::utcNow(), jsonResponse]);

Performance Rules

RuleWhy
Always require a filter on the primary keyPrevents full-table scans against the API
Cache aggressively (1-5 min TTL)Reduces API calls during form navigation
Limit result setsPage results if the API supports it
Set timeouts on HTTP callsA 30-second API timeout freezes the F&O form
Do not join virtual entities with large physical tablesThe join happens in memory, not in SQL

The Join Caveat

You can join a virtual entity with a physical table, but the runtime cannot push the join to SQL Server. It fetches the virtual entity results first, then joins in memory. If the physical table returns 50,000 rows and the virtual entity returns 100 rows, the join works fine. If both sides are large, performance degrades severely.

Pattern: Use virtual entities as lookup tables (small result sets), not as transaction tables (large result sets).

Error Handling and Resilience

When the external API is down, your virtual entity returns nothing — or throws an error. Plan for this:

  1. Circuit breaker pattern — after N consecutive failures, stop calling the API for M seconds. Return a cached result or an empty set with a warning
  2. Timeout configuration — set HTTP client timeouts to 5-10 seconds maximum. Users will not wait for a form to load for 30 seconds
  3. Graceful degradation — if the virtual entity fails, the rest of the form should still work. Do not make critical form logic depend on virtual entity data loading successfully

When Virtual Entities Are the Wrong Choice

Do not use virtual entities when:

  • You need to write back to the external system — virtual entities are read-only
  • You need to run batch processes against the data — batch jobs should not depend on external API availability
  • The external API has rate limits that your user base will exceed — 50 users opening vendor records simultaneously could hammer the API
  • You need the data for reporting or analytics — use Synapse Link or a proper data warehouse instead
  • The data volume per query exceeds 5,000 rows regularly — performance will suffer

The Takeaway

Virtual entities remove an entire layer of integration complexity for the right use cases. No staging tables, no batch jobs, no stale data, no storage costs. But they trade those benefits for a runtime dependency on an external system and the performance characteristics of that system.

Build them for lookup-style data that users need to see in context. Cache aggressively. Require filters. And always have a plan for when the external API is not available.

Comments

No comments yet. Be the first!