API Client SDK Generation
Key Points
- Hand-rolled
HttpClientdoesn't scale past a handful of endpoints. You drift from server contracts, repeat serialization, and re-wire retries / auth on every call site. - Three modern paths in .NET: Refit (interface-first, no IDL), Kiota (Microsoft's OpenAPI generator, .NET 8+ default), NSwag (long-standing OpenAPI generator).
- Kiota is Microsoft's preferred generator for new .NET 8+ work — the new Microsoft Graph SDK and Azure SDK clients are Kiota-generated.
- Refit wins when both ends are .NET and the team owns both — declare a C# interface, get a runtime client. Kiota wins when the source-of-truth is a published OpenAPI spec.
- Generated clients amplify versioning discipline. Operation IDs and schema names must be stable across releases — see API Versioning & Contracts.
- Pair generated clients with
IHttpClientFactoryand Polly v8 /Microsoft.Extensions.Resilience— the client handles serialization; the handler pipeline handles retries, auth, telemetry.
Concepts (deep dive)
The problem with hand-rolled HttpClient
public class OrdersClient(HttpClient http)
{
public async Task<Order?> GetAsync(int id, CancellationToken ct)
{
var resp = await http.GetAsync($"/orders/{id}", ct);
if (resp.StatusCode == HttpStatusCode.NotFound) return null;
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<Order>(cancellationToken: ct);
}
public async Task<Order> CreateAsync(CreateOrder cmd, CancellationToken ct)
{
var resp = await http.PostAsJsonAsync("/orders", cmd, ct);
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<Order>(cancellationToken: ct))!;
}
// ...30 more methods, each repeating the same pattern
}
What's wrong:
- Drift. Server adds a field; client breaks at runtime, not compile time.
- Serialization repetition.
ReadFromJsonAsync<T>()everywhere. - Status-code switch. 404 vs error vs success — bespoke per call.
- Retry / auth wiring. Repeated per call or hand-wired in delegating handlers.
- Test boilerplate. Mock
HttpClientviaHttpMessageHandlerfor each test.
For 5 endpoints, fine. For 50, you'll wish you'd generated.
Refit — interface-first, no IDL
public interface IOrdersApi
{
[Get("/orders/{id}")]
Task<Order> GetAsync(int id, CancellationToken ct = default);
[Post("/orders")]
Task<Order> CreateAsync([Body] CreateOrder cmd, CancellationToken ct = default);
[Put("/orders/{id}")]
Task UpdateAsync(int id, [Body] UpdateOrder cmd, CancellationToken ct = default);
[Delete("/orders/{id}")]
Task DeleteAsync(int id, CancellationToken ct = default);
[Get("/orders")]
Task<IReadOnlyList<Order>> ListAsync(
[Query] string? status = null,
[Query] int page = 1,
CancellationToken ct = default);
}
Wire it up:
builder.Services.AddRefitClient<IOrdersApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.example.com"))
.AddStandardResilienceHandler(); // Microsoft.Extensions.Resilience
Refit generates the implementation at runtime via IL emit. You inject IOrdersApi; you call methods; you get typed results. 404 returns null on Task<T?> (when T is reference type), or ApiException on Task<T>.
Strengths:
- C# is the source-of-truth. No external IDL.
- Minimal boilerplate — write the interface, call it.
- Interfaces are trivially mockable for tests.
- Plays well with
IHttpClientFactoryand resilience handlers.
Limitations:
- Runtime IL emit isn't AOT-friendly (Refit is working on source generators; check current status).
- File uploads require
MultipartItem/[Multipart]. - No streaming response support beyond
Stream/HttpContentreturns. - The interface only describes what your client cares about — it doesn't validate against the server's actual contract.
Best fit: internal microservices where one team owns both ends. The interface lives in a shared NuGet, both server and client reference it, both compile against the same contract.
Kiota — Microsoft's OpenAPI generator
kiota generate \
--openapi https://api.example.com/openapi.json \
--language CSharp \
--class-name OrdersClient \
--namespace-name Contoso.Orders.Client \
--output ./src/Generated
Generated code follows a fluent path-builder style:
var adapter = new HttpClientRequestAdapter(authProvider, httpClient: http);
var client = new OrdersClient(adapter);
var order = await client.Orders[42].GetAsync();
var orders = await client.Orders.GetAsync(c =>
{
c.QueryParameters.Status = "pending";
c.QueryParameters.Page = 1;
});
var created = await client.Orders.PostAsync(new CreateOrder { ... });
await client.Orders[42].DeleteAsync();
What's distinctive:
- Path → property.
/orders/{id}/linesbecomesclient.Orders[42].Lines. Strongly typed all the way. - Pluggable serialization. JSON / form / multipart / text — pick adapters.
- Pluggable auth.
IAuthenticationProviderfor token-based, API key, or anonymous. - Pluggable HTTP. Built on
IRequestAdapter— wrapsHttpClient. Integrates withIHttpClientFactory. - AOT-friendly. Generated code is plain C#; no runtime emit.
- Source-of-truth is the OpenAPI spec. If the server breaks the contract, regeneration breaks the build (loudly, where you want it).
The new Microsoft Graph SDK is Kiota-generated. The pattern scales to thousands of endpoints.
Auth example with Kiota + DefaultAzureCredential
public class TokenCredentialAuthProvider(TokenCredential cred, string scope) : IAuthenticationProvider
{
public async Task AuthenticateRequestAsync(
RequestInformation request,
Dictionary<string, object>? additional = null,
CancellationToken ct = default)
{
var token = await cred.GetTokenAsync(new TokenRequestContext([scope]), ct);
request.Headers.Add("Authorization", $"Bearer {token.Token}");
}
}
builder.Services.AddHttpClient<HttpClient>()
.AddStandardResilienceHandler();
builder.Services.AddSingleton<OrdersClient>(sp =>
{
var http = sp.GetRequiredService<IHttpClientFactory>().CreateClient();
var auth = new TokenCredentialAuthProvider(
new DefaultAzureCredential(),
"api://orders/.default");
var adapter = new HttpClientRequestAdapter(auth, httpClient: http)
{
BaseUrl = "https://api.example.com"
};
return new OrdersClient(adapter);
});
Managed identity → token credential → typed client. No secrets in config.
NSwag — the long-standing community choice
// nswag.json
{
"documentGenerator": {
"fromDocument": {
"url": "https://api.example.com/openapi.json"
}
},
"codeGenerators": {
"openApiToCSharpClient": {
"namespace": "Contoso.Orders.Client",
"className": "OrdersClient",
"useBaseUrl": true,
"operationGenerationMode": "MultipleClientsFromOperationId",
"output": "./src/Generated/OrdersClient.cs"
}
}
}
Generated code is a single C# class per service with methods per operation. Verbose but explicit.
Strengths:
- Mature; deep configuration via Liquid templates.
- Long history; many existing projects.
- Direct C# class output — easy to step through in a debugger.
Limitations:
- Generated output is verbose.
- Configuration sprawl (
nswag.jsonis deep). - Slower to support new OpenAPI features than Kiota.
Best fit: existing NSwag deployments where regeneration is reliable. New projects in 2026 generally start with Kiota.
Decision matrix
| Tool | Source of truth | Output style | AOT | Best for |
|---|---|---|---|---|
Hand-rolled HttpClient | Mental model | DIY | ✅ | <10 endpoints |
| Refit | C# interface | Runtime proxy | partial | Internal .NET-to-.NET |
| Kiota | OpenAPI spec | Fluent path-builder | ✅ | Spec-first; large APIs |
| NSwag | OpenAPI spec | Single class | partial | Existing NSwag projects |
Server-side discipline matters more
Once an SDK is generated and shipped, your OpenAPI surface is the contract. That means:
- Operation IDs must be stable. Renaming
getOrder→getOrderByIdbreaks every generated client. - Schema names must be stable. Renaming
Order→OrderDtobreaks generated types. - Versioning matters. Breaking changes go behind a new path/version. See API Versioning & Contracts and OpenAPI & Swagger.
- Required vs optional in the spec must reflect reality. A property marked required that the server omits in some responses → client deserialization explodes.
A senior reviewer treats every change to openapi.json like a public API change.
CI integration — generate-on-build vs check-in
Two approaches; both valid; pick one and stick:
Generate-on-build:
<Target Name="GenerateApiClient" BeforeTargets="BeforeBuild">
<Exec Command="kiota generate --openapi $(OpenApiSpec) --language CSharp --output Generated --class-name OrdersClient" />
</Target>
- ✅ Always in sync with current spec.
- ❌ Requires the spec be available at build time (often a network call).
- ❌ Generated code isn't in version control — diffs invisible in PRs.
Check-in generated:
# In a "regen-clients.sh" run manually or in CI
kiota generate ...
git add Generated/
git commit -m "Regen client for spec v1.4.2"
- ✅ Generated code in the diff — reviewers see breaking changes.
- ✅ No build-time network dependency.
- ❌ Easy to forget to regenerate.
- ❌ PR diffs become noisy when the spec churns.
Senior preference: check-in generated, with a CI gate that re-runs generation and fails the build if the working tree changes. That gives you visible diffs and enforced freshness.
Resilience
Generated clients should not implement retries themselves. Layer it through the HttpClient handler pipeline:
builder.Services.AddRefitClient<IOrdersApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(baseUrl))
.AddStandardResilienceHandler(o =>
{
o.Retry.MaxRetryAttempts = 3;
o.Retry.BackoffType = DelayBackoffType.Exponential;
o.CircuitBreaker.FailureRatio = 0.5;
o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10);
});
Microsoft.Extensions.Resilience (built on Polly v8) gives you retry, circuit breaker, timeout, and bulkhead in one handler. Same handler works with Refit, Kiota, and NSwag clients — they all wrap HttpClient underneath. See Resilience: Polly v8 and IHttpClientFactory Patterns.
The flow
[ Server: ASP.NET Core API ]
│ emits
▼
[ openapi.json ] (or shared C# interface for Refit)
│ fed into
▼
[ Generator: kiota / nswag / refit-source-gen ]
│ produces
▼
[ Generated C# client code ]
│ consumed by
▼
[ DI: IServiceCollection.AddHttpClient + handler pipeline ]
│ injects
▼
[ Caller: typed methods, retries via handler, auth via TokenCredential ]
How it works under the hood
Refit uses Castle.DynamicProxy-style runtime IL emit (or a source generator in newer versions) to implement your interface. At first call, it builds a proxy class that translates each method invocation into an HttpRequestMessage, awaits via the underlying HttpClient, and deserializes the response.
Kiota is a code generator written in Go. It reads the OpenAPI document, builds an internal model, and emits C# (or TypeScript, Java, Python, Go, PHP, Swift) following a fluent path-builder template. Generated code uses Kiota's own abstractions (IRequestAdapter, IAuthenticationProvider, IParseNode) so the runtime layer is swappable.
NSwag is a .NET tool. It parses OpenAPI, builds an in-memory model, and uses Liquid templates to render C# classes. Templates are user-customizable.
All three eventually produce code that uses HttpClient underneath. The handler pipeline (AddStandardResilienceHandler, DelegatingHandlers for auth/telemetry) sits between the typed client and the wire.
Code: correct vs wrong
❌ Wrong: hand-rolled HttpClient for 30 endpoints
public async Task<Order?> GetOrderAsync(int id) { /* 8 lines per call */ }
public async Task<Customer?> GetCustomerAsync(int id) { /* 8 lines per call */ }
// ...28 more variations, each subtly different.
✅ Correct: Refit (internal, .NET-to-.NET)
public interface IOrdersApi
{
[Get("/orders/{id}")] Task<Order?> GetAsync(int id);
[Get("/customers/{id}")] Task<Customer?> GetCustomerAsync(int id);
}
✅ Correct: Kiota (spec-first, external API)
❌ Wrong: regenerating without committing the diff
# Local dev: kiota generate ... && dotnet build
# CI: kiota generate ... && dotnet build
# Generated/ is in .gitignore — nobody sees the breaking change in the PR
✅ Correct: commit generated code; CI verifies it's fresh
# Local dev: kiota generate, commit Generated/
# CI: kiota generate; git diff --exit-code Generated/ # fails if drift
❌ Wrong: retries inside the generated client
// In a custom Kiota interceptor
for (var i = 0; i < 3; i++)
{
try { return await DoRequestAsync(); }
catch { await Task.Delay(...); }
}
✅ Correct: retries in the HttpClient handler pipeline
❌ Wrong: per-call JsonSerializerOptions mismatch
// Server: configures camelCase, ignores nulls
// Generated client: uses default options — PascalCase, includes nulls
// Result: half the fields don't bind on either end
✅ Correct: server emits the spec; client uses the matching adapter
Kiota's serialization adapters are negotiated by content-type. Match what your server actually returns. For Refit, configure RefitSettings.ContentSerializer once and align with the server.
Design patterns for this topic
Pattern 1 — "Spec-first contracts with Kiota"
- Intent: OpenAPI is the contract; clients are derived; breakage is loud.
Pattern 2 — "Interface-first contracts with Refit"
- Intent: C# interface in shared NuGet; both ends compile against it.
Pattern 3 — "Handler pipeline for cross-cutting"
- Intent: retries, auth, telemetry live in delegating handlers — not in the client.
Pattern 4 — "Check-in generated code with CI freshness gate"
- Intent: visible diffs in PRs + enforced regeneration.
Pattern 5 — "Token credential at the boundary"
- Intent: managed identity /
DefaultAzureCredentialflows into the client; no secrets per call.
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| Hand-rolled | Zero deps; full control | Drift; boilerplate |
| Refit | C# source of truth; tiny | Runtime emit; .NET-only |
| Kiota | Modern; AOT; multi-language | Different style; learning curve |
| NSwag | Mature; flexible templates | Verbose output; older feel |
| Polly handler | Cross-cutting in one place | Need to understand handler pipeline |
| TokenCredential | Secret-free auth | Cred chain debugging |
When to use / when to avoid
- Use Refit when both ends are .NET, both teams move together, and a shared interface fits.
- Use Kiota when you have an OpenAPI spec (yours or third-party) and want spec-first discipline.
- Use NSwag when you have an existing investment in it — fine, leave it.
- Use generated clients any time you have more than ~10 endpoints to call.
- Use
IHttpClientFactory+ standard resilience handler for every typed client. - Avoid hand-rolled
HttpClientpast the first few endpoints. - Avoid retries inside the generated client — keep them in the handler.
- Avoid renaming operations / schemas without a versioning strategy — every consumer breaks.
Interview Q&A
Q1. Why does Microsoft prefer Kiota over Swagger Codegen for .NET? Kiota is owned and maintained by Microsoft, AOT-friendly, supports the full HTTP stack via IRequestAdapter (pluggable serialization, auth, transport), and produces a fluent path-builder API that scales to thousands of endpoints. Microsoft Graph SDK is built with it.
Q2. Refit vs Kiota — pick one. Refit if both ends are .NET and a shared C# interface is the natural contract. Kiota if the contract is an OpenAPI spec (especially a third-party one) and you want spec-first discipline.
Q3. How do you version a generated client when the server ships v2? Generate a new client (different namespace, different class name) from the v2 spec. Coexist v1 and v2 clients during migration. The server's path-based versioning (/v1/... vs /v2/...) keeps them isolated. See API Versioning & Contracts.
Q4. Where do retries belong — generated client or handler? The handler. Generated client knows about HTTP and types; the delegating handler pipeline (Polly v8 / Microsoft.Extensions.Resilience) knows about retry, circuit breaker, timeout. Same handler works for Refit, Kiota, and NSwag clients.
Q5. Why is hand-rolled HttpClient still common? Inertia, small surface (1-3 endpoints), or distrust of generated code. For >10 endpoints, generated wins on every metric except first-day developer comfort.
Q6. Operation IDs — why do they matter? They become the C# method name in generated clients. Renaming an operation ID is a breaking change to every consumer's compiled code. Fix the operation IDs once; never rename.
Q7. What's DefaultAzureCredential and how does it fit? A credential chain (env vars → managed identity → VS sign-in → CLI). Pass to a Kiota IAuthenticationProvider or to a Refit auth handler. Token resolution happens per request; client code stays secret-free.
Q8. Generate-on-build vs check-in? Check-in with a CI freshness gate is the preferred pattern. Visible diffs in PRs + no build-time network dependency + enforced regeneration. Generate-on-build hides drift.
Q9. JSON option mismatches — common bug? Yes. Server emits camelCase + ignores nulls; client deserializes with default PascalCase + includes nulls. Match JsonSerializerOptions (or use the spec — Kiota infers from application/json schemas).
Q10. Streaming responses — which tool? Kiota and NSwag handle application/octet-stream and chunked transfers. Refit needs the method to return HttpResponseMessage or Stream directly — there's no automatic streaming.
Q11. Multipart file upload — Refit? [Multipart] Task UploadAsync(StreamPart file, string fileName); with MultipartItem parts. Kiota generates multipart-aware methods automatically when the spec describes them.
Q12. How do generated clients interact with IHttpClientFactory? They wrap an HttpClient instance. With AddRefitClient<T>() or AddHttpClient<HttpClient>() for Kiota, the factory manages handler lifetimes, DNS refresh, and resilience pipelines. Don't new HttpClient() for these.
Gotchas / common mistakes
- ⚠️ Renaming operation IDs — silently breaks every generated client.
- ⚠️
DateTimevsDateTimeOffsetmismatch — server emits one, client deserializes the other; values shift. - ⚠️ Default
JsonSerializerOptionsmismatch between server and client. - ⚠️ Retries baked into the client — duplicate retries when handler also retries.
- ⚠️ Generated code in
.gitignore— drift invisible in PRs. - ⚠️ Refit interface diverging from server — interface compiles, runtime fails.
- ⚠️ Token caching missing — every call hits the IdP.
- ⚠️
new HttpClient()per call in Kiota wiring — socket exhaustion. - ⚠️ Required field marked optional in OpenAPI — generated client allows null; runtime explodes.
- ⚠️ Changing schema name in OpenAPI — generated DTOs disappear; downstream code stops compiling.