Skip to content

Repository files navigation

Dynamics365.BusinessCentral

A lightweight, strongly-typed client for the Dynamics 365 Business Central OData API.

✨ Features

  • 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, honouring Retry-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 HttpClient and System.Text.Json

Upgrading from 1.x? See MIGRATION.md. Full history in CHANGELOG.md.

🎯 Scope

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.

📦 Installation

dotnet add package Dynamics365.BusinessCentral

🧩 Setup

Only 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 as DateOnly (System.Text.Json reads both real dates and the 0001-01-01 unset sentinel natively). Datetime fields (lastModifiedDateTime and friends) are Edm.DateTimeOffset and map to DateTimeOffset as usual.

🔍 Querying

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 $select and the whole request fails with a 400 naming 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. $select was measured case-insensitive on Business Central SaaS — three spellings of one column all returned 200, and the server answers in its own canonical casing regardless of what was requested. A [JsonPropertyName] whose casing disagrees with $metadata needs 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()

Streaming

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.

Counting and paging

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();

Expanding

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();

Path-based access

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")) { }

✏️ Writing

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);

🏢 Multiple companies

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();
}

🧪 Filters

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.Value directly always gives you the or-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=true nor $filter=false is a thing you can send. The package handles both client-side: Filter.All is emitted as no $filter, and Filter.None short-circuits to an empty result. Composition reduces them away too — Filter.All.And(x) is x, not (true) and (x).

Business Central limitation: or only 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. not does 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 prefer Filter.NotEquals (ne, AL <>) and Filter.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 null onto "is blank". Filter.IsNull on a text field therefore matches empty strings, and Filter.IsNotNull excludes them — unlike the equivalent LINQ predicate. Verified against a live tenant.

Query-string length and bulk key lookups

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 $filter is 942 characters as an or-chain against 438 natively — and &$schemaversion=2.1 costs 19, so it recovers essentially the whole difference and roughly doubles the keys that fit in one request. Setting o.SchemaVersion = "2.1" applies it to every Filter.In with no call-site changes. See Filters.

Field names without the builder

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)]);

⚡ Performance

Following Microsoft's OData client-performance guidance. Some of it the package already does for you; two settings are yours to turn on.

Read from a database replica

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.

Response language

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");

Already handled

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

Worth knowing

  • $top on its own is fine — Microsoft only discourages it combined with $skip. If you are ranking rather than sampling, pair Top(n) with OrderBy(...): without an explicit order the server's "top n" is not stable between calls.
  • Filter on LastModifiedOn for 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 $expand is expensive. Add a Where(...) or Top(n) alongside a costly expand rather than expanding the whole set.
  • $expand currently 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).

FlowFilters

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.

♻️ Throttling and retries

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.

Composing with an existing resilience pipeline

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.

Mapping exceptions in message-level retry policies

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.

⚠️ Errors

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

📊 Diagnostics

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.

🧰 Testing

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.

Validating projections against a real tenant

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.

About

A lightweight, strongly-typed client for the Dynamics 365 Business Central OData API.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages