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
| Scenario | Virtual Entity | Data Import | Dual-Write |
|---|---|---|---|
| Data freshness | Real-time | As stale as your schedule | Near-real-time |
| Data volume for reads | Low-moderate (< 10K rows typical) | Any volume | Any volume |
| Write-back needed | No | No (one direction) | Yes |
| Storage cost in F&O | Zero | Proportional to data | Proportional to data |
| Query performance | Depends on external API | Fast (local DB) | Fast (local DB) |
| Offline resilience | Fails if API is down | Data is local | Data 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:
| Property | Value |
|---|---|
| Is Virtual | Yes |
| Data Provider Class | SupplierQualityRatingProvider |
| Primary Key | VendorCode, Category |
| Public Entity Name | SupplierQualityRatings |
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
- 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
- Key Vault for credentials. The API key comes from Azure Key Vault, not a configuration table or hardcoded string
- 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
| Rule | Why |
|---|---|
| Always require a filter on the primary key | Prevents full-table scans against the API |
| Cache aggressively (1-5 min TTL) | Reduces API calls during form navigation |
| Limit result sets | Page results if the API supports it |
| Set timeouts on HTTP calls | A 30-second API timeout freezes the F&O form |
| Do not join virtual entities with large physical tables | The 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:
- 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
- Timeout configuration — set HTTP client timeouts to 5-10 seconds maximum. Users will not wait for a form to load for 30 seconds
- 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!