Seven months ago I shipped my first NuGet package, a strongly typed .NET client for the Business Central OData API.
Today I shipped v2.0.0.
And this is a much bigger release than the version number makes it look.
The public API is cleaner. Queries are strongly typed. Paging now follows Business Central instead of trying to outsmart it. Retries know the difference between a safe replay and a duplicate record. Errors are easier to reason about. There is a dedicated testing package.
And, most importantly, the library is no longer validated only against a fake HTTP transport. v2 has been exercised against a real Business Central tenant and in production at KRAL GmbH.
A few numbers from that process:
- a 118k-row sweep dropped from 119 Business Central requests to 6
- Entra token traffic dropped by ~93%
- a production test ran 1,029 Business Central calls with no failures we could attribute to the library
- one live date-filter test caught a difference of 1 row out of 329,485
That is the release story.
The rest of this post is what changed, why it matters, and what you need to know if you are upgrading from 1.x.
What is new in v2
A typed query builder
The biggest API change is the query builder.
Field names now come from property selectors and are resolved through the same JsonSerializerOptions used for deserialization. Your model and your query cannot quietly drift apart anymore.
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();
Put [BusinessCentralEntity("salesOrders")] on the class and the entity path lives with the entity instead of being repeated across call sites.
[BusinessCentralEntity("salesOrders")]
public sealed class SalesOrder
{
public string No { get; set; } = "";
public string Status { get; set; } = "";
public decimal Amount { get; set; }
}
The old lower-level APIs are still there. The builder is the path I would use for new code.
$select comes from the entity
Business Central can expose a lot of fields, especially once table extensions enter the picture.
So v2 derives $select from the settable scalar properties on T by default.
That means your entity class defines the projection once:
public sealed class SalesOrder
{
public string No { get; set; } = "";
public string Status { get; set; } = "";
public decimal Amount { get; set; }
}
and the client asks Business Central only for those columns unless you explicitly override it.
There is a migration edge here: if a property on your class does not exist on the published page, it now goes into $select and Business Central returns a 400.
That is deliberate, but it needs checking during an upgrade. More on that later.
Server-driven paging
This one made a very visible difference.
v1, and every 2.0 alpha for a while, paced large reads with $top and $skip.
v2 does not.
By default it sends no page size at all. Business Central decides the page size and returns an @odata.nextLink. The client follows that cursor until the read is complete.
On one 118k-row entity set, that changed the read from:
119 round trips → 6
It is not just faster. It is also safer.
Walking forward with $skip means concurrent inserts and deletes can move rows between page boundaries. Following the server-provided continuation avoids that whole class of problems.
If you want to cap per-response size, you can still do it explicitly with PageSize(...) or WithPageSize(...).
Retry that knows when not to retry
Business Central throttles. Networks fail. Gateways time out.
So v2 retries 429, 408, 502, 503 and 504, respects Retry-After, and adds jitter so parallel callers do not all wake up at the same instant.
But the important part is what it doesn’t retry blindly:
| Method | 429 | 408 / 502 / 503 / 504 |
|---|---|---|
GET, PUT, DELETE | retried | retried |
PATCH | retried | retried; this client sends absolute values, so replaying converges |
POST | retried | not retried |
A 429 is safe to replay because Business Central rejected it before processing the request.
A 504 after a POST is different. The write may already have happened. Replaying it can create a duplicate.
That distinction is why the README also documents how to compose the client with an existing resilience pipeline instead of pretending that stacking retry handlers is always harmless.
An exception model that is easier to use correctly
Everything derives from BusinessCentralException, but the concrete exception types are siblings rather than a deep inheritance tree.
That matters because this looks reasonable:
catch (BusinessCentralServerException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
but it will never catch a 404, because a 404 is a BusinessCentralNotFoundException.
v2 makes the base type useful instead:
catch (BusinessCentralException ex) when (ex.IsNotFound)
{
// already gone, so the delete basically succeeded
}
There are predicates for the cases application code actually cares about:
IsThrottledIsValidationIsAuthIsConnectionFailureIsUrlTooLongIsProtocolViolationIsTransientIsTokenAcquisitionFailure
The last one is particularly useful because it tells you whether OAuth2 failed before the request ever reached Business Central.
A testing package
v2 also ships:
Dynamics365.BusinessCentral.Testing
It gives you a real BusinessCentralClient over a scripted transport, so you can test what your code actually sends instead of only asserting that some mocked interface method was called.
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);
Mocking IBusinessCentralClient tells you that a query happened.
This tells you what the client actually asked Business Central for.
The improvements I could measure
Features are nice. Numbers are better.
~93% fewer Entra token requests
This is probably the most satisfying improvement in the release.
In v1 I had put the token cache on the client. Typed HttpClients are transient, so resolving a fresh IBusinessCentralClient could also mean asking Entra for another token.
The package worked, but the authentication traffic was absurd.
v2 moves token acquisition behind a shared provider.
During the production test at KRAL GmbH, token traffic dropped from roughly 0.305–0.428 token calls per Business Central call to 0.023.
That works out to roughly:
93% fewer Entra token requests
Nothing changed at the call sites. The library just stopped doing work it never needed to do.
119 requests became 6
The paging change had a similarly obvious effect.
A sweep over roughly 118,000 rows went from 119 Business Central requests to 6 once the client stopped pacing the server with $top / $skip and started following @odata.nextLink.
That is a performance improvement, but I care just as much about the correctness improvement. The cursor comes from Business Central. The client no longer invents its own position in a moving result set.
Production validation at KRAL
The live-tenant suite gave me confidence that Business Central accepted the assumptions built into v2.
The final step was different: put the release candidate into the production integration that actually depends on this package.
Before cutting stable, we upgraded the production integration at KRAL GmbH from 1.0.0 to 2.0.0-rc.1 and left it there for two working days.
1,029 Business Central calls. Zero failures we could attribute to the library. No rollback.
That number matters to me more than another few hundred isolated unit tests.
A scripted transport can prove URL construction, retry behaviour and deserialization. A live suite can prove that Business Central accepts the requests and behaves the way the client expects.
The production run tests something broader: whether the package still holds up when a real application uses it normally, with real authentication, real traffic and the actual Business Central tenant on the other side.
It also gave me the clearest measurement of one of v2’s biggest internal fixes.
During that same production run, Entra token traffic dropped from roughly 0.305–0.428 token calls per Business Central call to 0.023.
That is about:
93% fewer Entra token requests
There was no call-site change behind that number. The fix was entirely inside the package: token acquisition now sits behind a shared provider instead of a cache tied to transient client instances.
The field test was also deliberately boring in the best possible way. There was no rollback, no emergency patch, and no failure we could pin on the release candidate.
For me, that is the strongest proof point in the whole 2.0 release. The package did not just pass a suite designed around it. It survived the integration it was built for.
Testing against the real thing
v2 also changed how I think about testing this kind of client.
The package already had a large test suite. Around 300 unit facts run over a scripted HTTP transport on net8.0, net9.0 and net10.0.
Those tests are valuable. They exercise URL construction, filters, paging logic, retry behaviour and deserialization with a real BusinessCentralClient.
But they only prove our side of the contract.
They cannot prove that Business Central accepts the request.
What fake transports cannot prove
The clearest example was the OData in operator.
I had a green test for this:
$filter=no in ('EBH100','EBT200')
Then I sent it to a real tenant.
Business Central answered:
501 BadRequest_MethodNotImplemented
The syntax itself was valid OData. The problem was Business Central: native in is gated behind schema version 2.1.
The fake transport could never have found that because it responds according to the script I gave it.
That is not a missing unit test.
It is a different boundary.
So v2 has two test layers
The first layer is the scripted transport suite.
The second is a live-tenant suite that pins down the behaviours the package actually depends on:
| Fact | What it pins down |
|---|---|
| Paging | Business Central returns a nextLink, and following it returns every row exactly once |
| Date filters | kindless DateTime values behave consistently instead of depending on the runner’s timezone |
| Projections | derived $select fields resolve against real $metadata |
| Casing | $select is accepted case-insensitively and BC returns its own canonical casing |
| Schema version | native in fails below 2.1 and matches the portable or-chain when enabled |
| URL ceiling | over-length URLs produce 414, while requests inside the warning range still succeed |
One row out of 329,485
The date-filter test is the one that convinced me this layer was worth keeping.
The old v1 behaviour passed a DateTime with Kind = Unspecified through ToUniversalTime().
That means the same filter can mean something different depending on the local timezone of the machine running the code.
Against the live tenant, the corrected interpretation changed:
1 row out of 329,485
One row.
No exception. No obviously broken request. Just a different answer.
That is exactly the kind of bug a client library has to care about.
Keeping live tests safe
A live suite is only useful if it is safe enough to keep around.
For this one:
- the app registration only has access to the sandbox environment and was never granted access to Production
- a second guard blocks requests unless the resolved URL points at the expected Business Central sandbox host over HTTPS
- tests are read-only
- missing credentials skip locally rather than leaving the repository permanently red
- CI checks that credentials exist before running, because “all skipped” must not look like “all passed”
- the live workflow does not run on
pull_requestorworkflow_dispatch - tenant-owned values like row counts and page size are reported rather than hard-coded as package invariants
- comparisons over moving data are bracketed rather than compared with one fixed count
- timezone-sensitive tests fail if the runner environment makes the old and new behaviours indistinguishable
The goal is not to turn CI into a second production environment.
It is to keep a small set of tests that can fail when Business Central stops behaving the way this library assumes it behaves.
What v1 taught me
v1 did its job. It also taught me where this kind of package is fragile.
The most important bugs were not spectacular crashes. They were quiet:
QueryAllAsynccould silently miss rows when Business Central paged differently than expectedDateTimeKind.Unspecifiedcould make date filters depend on the machine timezone- the token cache lived at the wrong lifetime and caused needless Entra traffic
204 No Contenton a successful write could be treated as failure- alternate keys could be encoded incorrectly
- an apostrophe in a company name could break the OData path
- a
404from the token endpoint could be mistaken for “entity not found” - a repeated continuation cursor could be followed forever
Those bugs are worth talking about, but they are not the headline of v2.
The headline is that they turned into API changes, tests, migration checks and measurable improvements.
Upgrading from 1.x
Most 1.x code will still compile.
That is good for compatibility, but it also means the important migration changes are mostly behavioural.
The full details are in MIGRATION.md. These are the ones I would check first.
WithTop is now actually a result cap
In v1 QueryAllAsync(... WithTop(500)) used 500 as a page size.
In v2, WithTop(500) means what it says: return at most 500 rows.
// 1.x: fetched everything, 500 rows per round trip
await client.QueryAllAsync<SalesOrder>(
"salesOrders",
options: o => o.WithTop(500));
// v2 equivalent:
await client.QueryAllAsync<SalesOrder>(
"salesOrders",
options: o => o.WithPageSize(500));
If you leave the old line untouched, v2 returns 500 rows, tops.
This is the migration item I would check first.
Validate derived projections
Because v2 derives $select from your model, properties that do not exist on a published page can now fail the request.
The package ships a check for that:
await BusinessCentralMetadata.AssertProjectionsResolveAsync(
client,
typeof(Item).Assembly);
It pulls $metadata, derives the projection for every [BusinessCentralEntity] type and reports all mismatches together.
Make this a standing integration test.
A common problem is a shared base class containing system fields that are available on some published pages but not others.
Fix those properties with [JsonIgnore], or use .SelectAll() where that is genuinely what you want.
Casing drift is not a problem. Live testing showed that $select is case-insensitive on Business Central SaaS.
Re-check exception-based policies
429 is now BusinessCentralThrottledException, not BusinessCentralServerException.
Transport failures no longer escape as HttpRequestException; they become BusinessCentralConnectionException with the original exception on InnerException.
If you have Polly, Wolverine or custom retry logic matching the old exception types, re-key those policies.
Using BusinessCentralException predicates such as ex.IsTransient is usually the cleaner option.
Re-check DateTimeKind.Unspecified
v2 treats DateTimeKind.Unspecified as already-UTC instead of routing it through machine-local timezone conversion.
If your code intentionally means “local time”, make that explicit:
DateTime.SpecifyKind(value, DateTimeKind.Local)
Migration checklist
- Replace
WithTopused as page size withWithPageSize - Add
AssertProjectionsResolveAsyncas a standing integration test - Check shared base classes for fields that do not exist on every published page
- Audit
catch (BusinessCentralServerException) - Re-key policies that match
HttpRequestException - Re-check logic that assumed the old
QueryAllAsyncrow counts - Re-check code that expected
204 No Contentto throw - Check
DateTimefilters withKind = Unspecified - Re-check
.Top(n).CountAsync() - Re-check
GetAsynccalls that “always return null” - Decide whether to keep, tune or disable built-in retry
- Prefer the two-generic write overloads over
dynamic
AI-assisted review
AI was part of the 2.0 process, but it was not the proof that made me comfortable shipping it.
I used several coding agents during the release cycle: Claude Code, Codex, and a few purpose-built agents of my own. They got repeated full-solution review passes and were useful at finding the boring edge cases that are easy to miss when you have been staring at the same code for months.
That included things like URL escaping, exception paths and continuation handling.
The rule was simple: every finding had to be confirmed against the actual code before it became a fix. A convincing explanation from an agent was never enough on its own.
That review work helped harden the package. Package validation caught other things mechanically. The live-tenant suite checked assumptions against Business Central.
But the production field test at KRAL GmbH is what gave me confidence to call this release 2.0.
The agents helped me find things. The field test proved the package could actually hold up.
Why I am confident shipping v2
v2 is not “done” because every test is green.
It is ready because it has been tested from several different angles:
- hundreds of scripted transport tests
- a live Business Central tenant
- package validation
- repeated review rounds with multiple coding agents
- a production run at KRAL GmbH
Those layers answer different questions. The scripted tests prove what the client sends. The live suite proves what Business Central accepts. Review and package validation catch implementation and API mistakes.
The production run is the final layer, and the one that matters most to me.
The package did not just survive a synthetic benchmark. It handled 1,029 real Business Central calls, reduced Entra token traffic by about 93%, and stayed in place without a rollback.
There will still be edge cases. Business Central is too large, too configurable and too extensible for any client library to pretend otherwise.
But v2 is a much stronger foundation than v1.
If you are building against Business Central from .NET, I would love for you to try it.
And if your tenant proves one of my assumptions wrong, open an issue.
That is how this release got better in the first place.
What is next
v2 targets the OData surface: the endpoints you get by publishing pages.
That is not the only way into Business Central, and it is not always the right one.
The next thing I want to tackle is the standard REST API (/api/v2.0).
It is a genuinely different surface rather than a variation on this one. Microsoft maintains the entity definitions, so the shape does not depend on whatever a published page happens to expose, and it handles concurrency with ETag and If-Match rather than last-write-wins.
That makes it a second surface with its own metadata, its own constraints and its own set of assumptions. And if v2 taught me anything, those assumptions only get settled against a real tenant.
So there is no date attached to this one.
If you have a strong opinion about which surface should come first, that is worth an issue too.
Links
- GitHub: KralGmbh/Dynamics365-BusinessCentral
- NuGet: Dynamics365.BusinessCentral
- Testing package: Dynamics365.BusinessCentral.Testing
- Migration guide: MIGRATION.md
- Changelog: CHANGELOG.md
