A lightweight, strongly-typed client for the Dynamics 365 Business Central OData API.
- Strongly-typed queries — field names come from your entity, not from strings
- Built-in OAuth2 client credentials authentication, with a shared token cache
- Automatic retry of throttled (
429) and transient failures, honouringRetry-After - Streaming and automatic paging, including server-driven
@odata.nextLink - Filtering, ordering, projection, expansion and counting
- Multi-company support from a single registration
- Clean DI integration
- No runtime dependencies beyond
HttpClientandSystem.Text.Json
Upgrading from 1.x? See MIGRATION.md. Full history in CHANGELOG.md.
Worth knowing before you adopt, because it is not obvious from the API surface.
Targets the OData v4 published-pages endpoint on Business Central SaaS —
…/ODataV4/Company('NAME')/entitySet, the surface you get by publishing a page as a web
service. URLs are built around the Company('NAME') segment.
Not supported yet:
| Status | |
|---|---|
/api/v2.0 standard REST API |
Not supported — uses a different URL shape (companies({guid})/…) |
| Business Central on-premises | Untested. The OData stack differs and authentication is not client credentials |
Custom API pages (/api/publisher/group/v1.0) |
Untested — same URL-shape question as /api/v2.0 |
/api/v2.0 support is tracked for 2.1 in #54.
On behaviour measured against real tenants. Several defaults encode observations from
live Business Central deployments — that Filter.In must render an or-chain, that
$select is case-insensitive, how paging responds. Tenants vary, and these were not
measured everywhere, so anything derived from one deployment is overridable at registration
rather than baked in. Where a default rests on a measurement, its XML documentation says so.
dotnet add package Dynamics365.BusinessCentralOnly four settings are required. BaseUrl and TokenEndpoint default to the Business
Central SaaS endpoints and understand the {tenant} and {environment} placeholders.
services.AddBusinessCentral(options =>
{
options.TenantId = "your-tenant-id";
options.ClientId = "your-client-id";
options.ClientSecret = "your-client-secret";
options.Company = "CRONUS AG";
// Optional — defaults to "Production"
options.Environment = "Sandbox";
});Or bind from configuration:
services.AddBusinessCentral(builder.Configuration.GetSection("BusinessCentral"));{
"BusinessCentral": {
"TenantId": "...",
"ClientId": "...",
"ClientSecret": "...",
"Company": "CRONUS AG",
"Environment": "Production",
"Retry": { "MaxAttempts": 3 }
}
}Then inject IBusinessCentralClient:
public class MyService(IBusinessCentralClient client)
{
public Task<List<SalesOrder>> GetOpenOrders() =>
client.Query<SalesOrder>()
.Where(Filter.Equals<SalesOrder>(o => o.Status, "Open"))
.ToListAsync();
}Point an entity at its OData entity set once, and stop repeating the path:
[BusinessCentralEntity("salesOrders")]
public sealed class SalesOrder
{
public string No { get; set; } = "";
public string Status { get; set; } = "";
public decimal Amount { get; set; }
// BC Edm.Date fields are date-only on the wire ("2026-10-28"), and unset dates come
// back as "0001-01-01" — map them to DateOnly. A bare DateTimeOffset property fails
// deserialization on EVERY row, populated or not, because neither form carries a time.
public DateOnly PostingDate { get; set; }
}Dates: Business Central date fields are
Edm.Date— always date-only, never timestamps. Model them asDateOnly(System.Text.Jsonreads both real dates and the0001-01-01unset sentinel natively). Datetime fields (lastModifiedDateTimeand friends) areEdm.DateTimeOffsetand map toDateTimeOffsetas usual.
Query<T>() is the recommended entry point. Field names come from property selectors, so
they survive renames and always match how the entity is deserialized.
var orders = await client.Query<SalesOrder>()
.Where(f => f.Equals(o => o.Status, "Open")
.And(f.GreaterThan(o => o.Amount, 100)))
.OrderByDescending(o => o.Amount)
.ThenBy(o => o.No)
.Top(50)
.ToListAsync();The Where(f => ...) builder infers the entity type from the query, so it is never
restated per operator; the static Filter.Equals<SalesOrder>(...) form remains available
and renders identically.
$select is derived from the entity by default: the query above sends
$select=Sell_to_Customer_No,amount,no,status — the settable scalar properties of
SalesOrder, resolved exactly like deserialization. The entity class states the
projection once; call sites stop restating it. An explicit .Select(...) narrows it,
.SelectAll() requests every column. Navigation properties and get-only computed
properties are excluded automatically.
A property with no matching column fails the query. Nothing in the package can consult your tenant's schema, so every derived name is validated by the server. A property that maps to no column on the entity set used to bind as its default and cost nothing; it now enters
$selectand the whole request fails with a400naming the field. Remedy with[JsonIgnore]on the property or.SelectAll()on the query — the exception message names both. Watch for a shared base class of system fields inherited by entity sets that do not all expose them.Catch it in CI, not in production. No unit suite detects this — mocks do not validate
$select, and neither does a transport fake. The Testing package ships a one-line check:await BusinessCentralMetadata.AssertProjectionsResolveAsync(client, typeof(Item).Assembly);See Testing.
Casing is not a problem.
$selectwas measured case-insensitive on Business Central SaaS — three spellings of one column all returned200, and the server answers in its own canonical casing regardless of what was requested. A[JsonPropertyName]whose casing disagrees with$metadataneeds no action. (Not measured on-premises.)
| Operation | Method |
|---|---|
| One page | ToListAsync() |
| Everything, auto-paged | ToAllAsync() |
| Everything, lazily | StreamAsync() |
| Page plus total | ToPageAsync() |
| Single row | FirstOrDefaultAsync() |
| Count only | CountAsync() |
StreamAsync fetches pages as you consume them and stops fetching when you stop reading —
prefer it over ToAllAsync for large sets.
Paging is server-driven (verified against a live BC SaaS tenant): by default no page
size is sent at all, Business Central pages at its own configured Max Page Size (20,000
online), and continuation follows @odata.nextLink — an opaque cursor immune to the
row-shift hazards of offset paging. To bound per-response size — memory, slow pages,
timeouts — request smaller pages; the value is sent as Prefer: odata.maxpagesize and
the server clamps it to its own maximum, so it can only ever ask for less:
// per registration — the default for every streaming read
services.AddBusinessCentral(o => { /* ... */ o.MaxPageSize = 1000; });
// per query — overrides the registration value
await foreach (var order in client.Query<SalesOrder>().PageSize(500).StreamAsync())
{
if (Process(order) is Done) break; // no further pages are requested
}Top(n) remains a pure result cap: it is sent as $top so the server never over-serves a
capped query, and enforced mid-page while continuations are followed.
var page = await client.Query<SalesOrder>().Top(50).ToPageAsync();
Console.WriteLine($"{page.Items.Count} of {page.TotalCount}");
var total = await client.Query<SalesOrder>()
.Where(Filter.Equals<SalesOrder>(o => o.Status, "Open"))
.CountAsync();var orders = await client.Query<SalesOrder>()
.Expand(o => o.Lines)
.ToListAsync();
// Raw OData expand syntax also works
var withNested = await client.Query<SalesOrder>()
.Expand("salesOrderLines($select=lineNo,amount)")
.ToListAsync();The lower-level API remains available when you do not want an annotated type.
var orders = await client.QueryAsync<SalesOrder>("salesOrders", Filter.Equals("status", "Open"));
var all = await client.QueryAllAsync<SalesOrder>("salesOrders");
var raw = await client.QueryRawAsync<JsonElement>("salesOrders?$top=5");
// Single-entity reads. GetAsync returns null on 404 — "does it exist" is a question,
// not an error. Keys may be a systemId or an alternate key.
var one = await client.GetAsync<SalesOrder>("salesOrders", "No='1000'");
var first = await client.FirstOrDefaultAsync<SalesOrder>("salesOrders", Filter.Equals("status", "Open"));
await foreach (var o in client.QueryStreamAsync<SalesOrder>("salesOrders")) { }await client.PostAsync("salesOrders", new SalesOrder { No = "1000" });
await client.PatchAsync("salesOrders", "No='1000'", new { Status = "Released" });
await client.PutAsync("salesOrders", systemId, order, ifMatch: etag);
await client.DeleteAsync("salesOrders", systemId);Writes send Prefer: return=representation. If the server answers 204 No Content, the
payload you sent is returned instead of throwing. (Measured live: ODataV4 page endpoints
echo the entity on PATCH regardless of the header, so the 204 path is a safety net
rather than the common case.) Keys may be a systemId or an alternate
key such as No='1000'.
When the response type differs from the payload — posting an anonymous object and reading
back an entity — use the two-generic overloads instead of dynamic:
var created = await client.PostAsync<object, CreatedRow>(
"ldatSummary",
new { serialNo = "S1", productionOrderNo = "PO1" });
// null means BC applied the write but returned no representation. Failures throw.
if (created is null) { /* handle "created but not echoed" */ }TResult is unconstrained, so JsonElement works when you have no model:
var element = await client.PostAsync<object, JsonElement>("ldatSummary", payload);One registration serves every company in the tenant. ForCompany shares the underlying
HTTP client and token cache, so it costs nothing.
foreach (var company in await client.GetCompaniesAsync())
{
var scoped = client.ForCompany(company.Name);
var orders = await scoped.Query<SalesOrder>().ToAllAsync();
}Every method has a string overload and a typed overload.
| Method | Expression |
|---|---|
Filter.Equals |
field eq value |
Filter.NotEquals |
field ne value |
Filter.GreaterThan |
field gt value |
Filter.GreaterOrEqual |
field ge value |
Filter.LessThan |
field lt value |
Filter.LessOrEqual |
field le value |
Filter.Contains |
contains(field, value) |
Filter.StartsWith |
startswith(field, value) |
Filter.EndsWith |
endswith(field, value) |
Filter.In |
(field eq v1) or (field eq v2) ... |
Filter.IsNull |
field eq null |
Filter.IsNotNull |
field ne null |
Combine with .And(...), .Or(...) and .Not():
var filter = Filter.Equals<SalesOrder>(o => o.Status, "Open")
.And(Filter.GreaterThan<SalesOrder>(o => o.Amount, 100));Filter.In picks its rendering from your configuration. Business Central supports the OData
in operator, but only from schema version 2.1;
below that it answers 501. So tell the client which you have, once:
services.AddBusinessCentral(o => o.SchemaVersion = "2.1");and every Filter.In switches to no in ('A','B') — no call-site changes. Without it,
they render the portable same-field or-chain (no eq 'A') or (no eq 'B'). Both return
identical rows where both work, verified against a live tenant; the in form is about half
the encoded width, which roughly doubles the keys that fit in one request.
The choice is made when the request URL is built, so composing with .And(...) keeps it —
which matters, because a chunked key lookup is almost always combined with something else.
Pin a rendering per call with Filter.In(field, values, ODataInStyle.OrChain) (or .Native),
or globally with o.InStyle.
Reading
ODataFilter.Valuedirectly always gives you theor-chain: a bare filter value has no endpoint to ask. What goes on the wire is what the configured client rendered.
With an empty collection Filter.In yields Filter.None, which the client answers with an
empty result and no request at all — so passing an empty key set is safe and free. A single
value collapses to eq.
There is no boolean-literal filter. Business Central's supported filter set is field-and-operator only, so neither
$filter=truenor$filter=falseis a thing you can send. The package handles both client-side:Filter.Allis emitted as no$filter, andFilter.Noneshort-circuits to an empty result. Composition reduces them away too —Filter.All.And(x)isx, not(true) and (x).
Business Central limitation:
oronly works between filters on the same field. Combining filters on different fields with.Or(...)—field1 eq 1 or field2 eq 2— has no AL filter equivalent and the server rejects it with "The 'OR' operator is not supported on distinct fields on an OData filter." No schema version lifts this; the remedies are one request per field, or an AL API page exposing the combination as a single filterable column..And(...)has no such restriction.
.Not()is not a documented Business Central filter.notdoes not appear in Microsoft's supported filter expressions, which are field-and-operator only, and Microsoft documents that an expression with no AL approximation is rejected. This has not been measured against a live tenant, so treat it as undocumented rather than as known to fail — but preferFilter.NotEquals(ne, AL<>) andFilter.IsNotNull, which are documented, wherever they express what you need.
Null means blank on text fields: AL text fields cannot be null — an unset field is an empty string, and Business Central maps
eq nullonto "is blank".Filter.IsNullon a text field therefore matches empty strings, andFilter.IsNotNullexcludes them — unlike the equivalent LINQ predicate. Verified against a live tenant.
Business Central's gateway limits the query string, not the whole URL. Measured against a
live SaaS tenant across two environments: the ceiling sits at 8,099 accepted query-string
characters and holds still, while the full URL moves with environment name, company name and
entity-set path. Past it the server answers 414 URI Too Long.
services.AddBusinessCentral(o =>
{
o.QueryStringLengthWarningThreshold = 6000; // default — raises OnUrlLengthWarning
o.MaxQueryStringLength = 8000; // default — throws BusinessCentralUrlTooLongException
});A request between the two is sent normally and reported to your observer, so a deployment
can discover the length distribution its real workload produces. Past MaxQueryStringLength
the client throws BusinessCentralUrlTooLongException before the request leaves the process,
naming the query-string length, the limit, and the or-clause count when one is present — all
of them also available as properties (QueryStringLength, Limit, OrClauseCount,
UrlLength) so a log can route on them.
It is a BusinessCentralException like every other failure, with StatusCode 0 because
nothing was sent and IsUrlTooLong to tell it apart from a connection failure, which shares
that status. It is never transient — the same call produces the same length every time.
The value here is pre-flight diagnosis, not decoding an opaque server error — 414 already
says what happened. What it does not say is which filter, how many or clauses, or that
Filter.In is the likely cause. Set either option to null to disable it.
Server-issued @odata.nextLink continuations are never checked — the server produced them,
so its own limits already applied.
public void OnUrlLengthWarning(BusinessCentralUrlLengthInfo url) =>
_logger.LogWarning("BC query string {Length} chars ({OrClauses} or-clauses), limit {Limit}",
url.QueryStringLength, url.OrClauseCount, url.Limit);The cheapest fix is usually schema version 2.1. For 25 eight-character keys the encoded
$filteris 942 characters as anor-chain against 438 natively — and&$schemaversion=2.1costs 19, so it recovers essentially the whole difference and roughly doubles the keys that fit in one request. Settingo.SchemaVersion = "2.1"applies it to everyFilter.Inwith no call-site changes. See Filters.
Path-based calls take field names as strings, which invites hand-maintained constants
classes that drift from the model. BusinessCentralField.Of resolves a selector exactly
the way deserialization does — [JsonPropertyName] first, then the camelCase policy — so
wire names live in one place, the entity. EntityPath.For<T>() does the same for the
entity set path:
var lines = await client.QueryAsync<ProdOrderLine>(
EntityPath.For<ProdOrderLine>(), // path from [BusinessCentralEntity]
Filter.Equals<ProdOrderLine>(l => l.OrderNo, orderNo), // typed filters resolve the same way
select: [BusinessCentralField.Of<ProdOrderLine>(l => l.ItemNo),
BusinessCentralField.Of<ProdOrderLine>(l => l.Quantity)]);Following Microsoft's OData client-performance guidance. Some of it the package already does for you; two settings are yours to turn on.
Microsoft's first recommendation. Data-Access-Intent: ReadOnly lets Business Central answer
a GET from a replica, taking load off the primary:
services.AddBusinessCentral(o =>
{
o.DataAccessIntent = BusinessCentralDataAccessIntent.ReadOnly;
});Opt-in, and deliberately so. Where a replica is genuinely used, replication lag means a
read issued straight after a write may not see it — right for a sync or reporting job, wrong
for a read-after-write flow, and the package cannot tell which yours is. The header is only
ever sent on GET; Microsoft documents that writes reject ReadOnly outright, so the client
never attaches it to POST/PATCH/PUT/DELETE.
Accept-Language fixes the language of Business Central's error messages — worth setting so
logs don't vary with tenant configuration. Microsoft notes it also governs regional
formatting of responses.
services.AddBusinessCentral(o => o.AcceptLanguage = "en-US");| Guidance | How the package follows it |
|---|---|
"Specify the columns you care about in the $select clause" |
The fluent builder derives $select from the entity type — see Querying. Business Central supports table extensions, so omitting $select returns every field including ones added by other extensions; this is why the derived projection is on by default |
"Do not use $top and $skip to implement client-driven paging" |
Streaming reads follow @odata.nextLink. There is no $skip loop; $skip is sent only when you ask for a starting offset |
| "Using server-driven paging" | The default. No page size is sent unless you set one, and then as Prefer: odata.maxpagesize |
$topon its own is fine — Microsoft only discourages it combined with$skip. If you are ranking rather than sampling, pairTop(n)withOrderBy(...): without an explicit order the server's "top n" is not stable between calls.- Filter on
LastModifiedOnfor historical queries. Business Central updates that system field on write, which makes it the efficient choice for "changed in the last 30 days" windows. - Limit the set when
$expandis expensive. Add aWhere(...)orTop(n)alongside a costly expand rather than expanding the whole set. $expandcurrently sends no inner$select, so an expanded entity comes back with all its properties. Pass the expand clause as a string to narrow it yourself —Expand("salesOrderLines($select=lineNo,quantity)")— until this is derived automatically (#55).
Business Central exposes a page's FlowFilters
as ordinary Edm.String properties named *_Filter. They do not filter rows — they
parameterise the FlowField calculations on the rows you get back:
// Qty_on_Sales_Order comes back calculated for the GREEN location only
var item = await client.Query<ItemCard>()
.Where(f => f.Equals(x => x.No, "1906-S")
.And(f.Equals(x => x.LocationFilter, "GREEN")))
.FirstOrDefaultAsync();Only the FlowFilters needed by FlowFields actually shown on the page appear in $metadata,
so the set is usually smaller than the table defines.
Business Central throttles aggressively. Throttled (429) and transient (408, 502,
503, 504) responses are retried automatically, honouring Retry-After when present.
Delays are jittered (Retry.JitterFactor, default 0.2) so concurrent callers do not
retry in lockstep — the spread is only ever added, never subtracted from a Retry-After.
Token acquisition follows the same retry options; bad credentials are not retried.
services.AddBusinessCentral(options =>
{
options.Retry.MaxAttempts = 5;
options.Retry.BaseDelay = TimeSpan.FromSeconds(2);
options.Retry.MaxDelay = TimeSpan.FromSeconds(30);
// options.Retry.Enabled = false; // surface transient failures immediately
});Writes are not blindly replayed. A 429 is rejected before Business Central processes
it, so replaying is always safe. The other transient statuses are ambiguous — the write may
already have been applied — so:
| Method | 429 |
408 / 502 / 503 / 504 |
|---|---|---|
GET, PUT, DELETE |
retried | retried (idempotent — replay converges) |
PATCH |
retried | retried (see below) |
POST |
retried | not retried; the exception is raised |
PATCH is replayed too. RFC 9110 does not guarantee PATCH is idempotent, but this client
only ever sends a JSON merge of absolute field values, so applying it twice converges on the
same state. If your payload carries relative operations, disable retries or pass a real
If-Match ETag instead of *.
Without this, a 504 on a POST could duplicate a record that Business Central had already
created. If your endpoint deduplicates server-side, or duplicates are acceptable, opt back
in with options.Retry.RetryPostOnTransientFailures = true.
Connection failures and client-side timeouts — no response received at all — are just as
ambiguous as a 504 and follow the same column: idempotent methods are retried, POST is
not. They surface as BusinessCentralConnectionException.
If a global handler wraps every HttpClient — e.g. .NET Aspire's
ConfigureHttpClientDefaults with AddStandardResilienceHandler — the outer retry and
this package's retry compose multiplicatively. Worse, the standard handler replays POST
on ambiguous failures, which this package deliberately refuses to do; with both active,
the outer handler retries before the package ever sees the failure.
Prefer exempting the package's clients and keeping the built-in retry, which honours
Retry-After and knows which requests are safe to replay. Both clients are addressable
by name:
services.AddHttpClient(BusinessCentralHttpClients.Client).RemoveAllResilienceHandlers();
services.AddHttpClient(BusinessCentralHttpClients.Token).RemoveAllResilienceHandlers();Exempting the token client is safe: token acquisition has its own retry under the same
Retry options, so removing the outer handler does not leave it bare. The per-attempt
timeout of the data client is configurable via options.RequestTimeout (default: the
HttpClient 100s) — budget Retry.MaxAttempts × (RequestTimeout + backoff) against any
outer execution timeout.
If a message bus or job runner (Wolverine, MassTransit, Hangfire, Polly) retries around
client calls, key its policies on this package's exception types — not on
HttpRequestException, which the client never lets escape:
| Match | Meaning | Suggested policy |
|---|---|---|
BusinessCentralConnectionException |
no response — connection failure / timeout (already retried in-process) | fast requeue curve |
BusinessCentralThrottledException |
429 — the client already honoured Retry-After |
slow requeue curve |
ex.IsTransient |
any retry-worthy failure | generic transient handling |
ex.IsUrlTooLong |
the query string exceeded MaxQueryStringLength; never sent |
dead-letter / alert — chunk the input |
ex.IsProtocolViolation |
a continuation pointed off-origin or stopped advancing | dead-letter / alert — the response is at fault |
| everything else | validation/auth/not-found — retrying cannot help | dead-letter / alert |
A policy keyed on HttpRequestException written for 1.x silently stops matching on
2.0 — transport failures surface as BusinessCentralConnectionException with the original
exception as InnerException.
Disabling the package's retry instead (options.Retry.Enabled = false) also resolves the
composition, but leaves the generic outer handler in charge — including its unsafe POST
replay.
All failures derive from BusinessCentralException. Message is a single line suitable
for logging; the detail lives on properties, and ToString() renders everything.
| Type | When |
|---|---|
BusinessCentralValidationException |
400 |
BusinessCentralAuthException |
401, 403 |
BusinessCentralNotFoundException |
404 |
BusinessCentralThrottledException |
429 |
BusinessCentralConnectionException |
no response — connection failure or client-side timeout; StatusCode is 0 |
BusinessCentralUrlTooLongException |
refused before sending: query string past MaxQueryStringLength; StatusCode is 0, Method empty |
BusinessCentralProtocolException |
refused to follow a bad @odata.nextLink — wrong origin, or a cursor that never advances; StatusCode is 0, Method empty |
BusinessCentralServerException |
everything else, and deserialization failures |
Three types carry StatusCode 0, because no HTTP status is associated with the failure
itself. Tell them apart with IsConnectionFailure (the network was tried and failed),
IsUrlTooLong (the client refused to send) and IsProtocolViolation (the client refused to
follow where a response pointed).
catch (BusinessCentralException ex)
{
logger.LogError(ex, "BC call failed: {Code} {CorrelationId}",
ex.ODataErrorCode, ex.CorrelationId);
if (ex.IsTransient) { /* safe to try again */ }
}The subtypes are sealed siblings, not a hierarchy — a guard like
catch (BusinessCentralServerException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
compiles but can never match, because a 404 is a BusinessCentralNotFoundException.
Prefer the predicates on the base type, which make the safe form the obvious one:
catch (BusinessCentralException ex) when (ex.IsNotFound)
{
// already gone — treat the delete as idempotent success
}IsNotFound, IsThrottled, IsValidation, IsAuth, IsConnectionFailure,
IsUrlTooLong, IsProtocolViolation and IsTransient cover the same distinctions as the
subtypes, without the trap.
One more flag cuts across all of them: IsTokenAcquisitionFailure is true when the failure
came from the OAuth2 token endpoint rather than from Business Central. Both report through
this hierarchy, so without it a misconfigured TokenEndpoint answering 404 is
indistinguishable from “no such entity”.
There is no logging dependency. Implement IBusinessCentralObserver to hook requests,
retries and the token lifecycle:
services.AddObserver<MyObserver>();Every member except the core request callbacks has a default implementation, so you only override what you care about.
The companion package Dynamics365.BusinessCentral.Testing
runs a real client over a scripted transport, so tests exercise URL building, filter
rendering, paging, retry and deserialization — and can assert the exact OData a call
produced, which a mock of IBusinessCentralClient never can:
using var bc = new FakeBusinessCentral();
bc.EnqueuePage(new Item { No = "X", Description = "Pump" });
var items = await bc.Client.QueryAsync<Item>("items",
Filter.Equals("no", "X"), select: ["no", "description"]);
Assert.Equal(
"/Company('TEST')/items?$filter=no eq 'X'&$select=no,description",
bc.Requests.Single().DecodedPathAndQuery);Multi-page responses (EnqueuePage(..., nextLink: "page2")), failures by status code
(EnqueueError(HttpStatusCode.TooManyRequests, retryAfter: …) raises the matching
exception subtype) and network failures are all scriptable; token acquisition is answered
automatically. For stateful fakes, every IBusinessCentralClient member has a default
implementation, so a hand-written fake implements only the members it uses.
A transport fake proves what OData you generate, never what Business Central accepts — so it
cannot tell you whether a derived $select names a real column. That gap has a dedicated
check, which needs a live (non-production) tenant:
[Fact] // integration test, pointed at a sandbox tenant
public async Task Every_entity_projection_resolves()
=> await BusinessCentralMetadata.AssertProjectionsResolveAsync(
_client, typeof(Item).Assembly);It derives the $select for every [BusinessCentralEntity] type in the assembly and fails
listing every name that matches no column, so one run tells you everything rather than one
400 at a time. ValidateAsync returns the report instead of throwing.
Worth running on every build rather than once at upgrade: the failure is introduced by adding a property, which is not an edit anyone associates with a query breaking.