Setting up a dotnet 8 dependency injection llm client correctly separates transport concerns from business logic and makes provider swaps painless. In .NET 8, the built-in DI container plus HttpClientFactory gives you everything you need to call OpenAI-compatible endpoints without dragging SDK baggage through your domain.
Step 1: Define a narrow interface for the LLM client
Don’t inject the OpenAI SDK or a concrete HttpClient directly into your services. Define a minimal contract that reflects what your application actually needs: chat completions. This keeps your domain testable and lets you swap providers or mock the network in unit tests.
public record Message(string Role, string Content);
public record ChatRequest(string Model, Message[] Messages);
public record Usage(int PromptTokens, int CompletionTokens, int TotalTokens);
public record Choice(Message Message);
public record ChatCompletion(string Id, string Model, Usage Usage, Choice[] Choices);
public interface ILLMClient
{
Task<ChatCompletion> CompleteAsync(ChatRequest request, CancellationToken ct = default);
}
The interface returns only the fields you use. Avoid leaking provider-specific response wrappers into your service layer.
Step 2: Implement the client against an HTTP API
A concrete implementation should be dumb: serialize the request, send it, deserialize the response, log token usage. Everything else belongs in pipeline handlers or the DI configuration.
public class OpenAICompatibleClient : ILLMClient
{
private readonly HttpClient _http;
private readonly ILogger<OpenAICompatibleClient> _logger;
public OpenAICompatibleClient(HttpClient http, ILogger<OpenAICompatibleClient> logger)
{
_http = http;
_logger = logger;
}
public async Task<ChatCompletion> CompleteAsync(ChatRequest request, CancellationToken ct = default)
{
using var httpReq = new HttpRequestMessage(HttpMethod.Post, "v1/chat/completions");
httpReq.Content = JsonContent.Create(request);
var resp = await _http.SendAsync(httpReq, ct);
resp.EnsureSuccessStatusCode();
var result = await resp.Content.ReadFromJsonAsync<ChatCompletion>(cancellationToken: ct);
if (result is null) throw new InvalidOperationException("Empty LLM response");
_logger.LogInformation("LLM {Model} used {Total} tokens", result.Model, result.Usage.TotalTokens);
return result;
}
}
This client is async, respects CancellationToken, and never blocks. It also avoids static HttpClient instances, which the factory manages for you.
Step 3: Register the client in the .NET 8 DI container
In Program.cs, use AddHttpClient<T> so the container injects a properly pooled HttpClient with the correct base address and auth header. This is where your dotnet 8 dependency injection llm client becomes production-ready.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<OpenAICompatibleClient>(client =>
{
var baseUrl = builder.Configuration["LLM:BaseUrl"]
?? throw new InvalidOperationException("LLM:BaseUrl missing");
client.BaseAddress = new Uri(baseUrl);
client.DefaultRequestHeaders.Add("Authorization",
$"Bearer {builder.Configuration["LLM:ApiKey"]}");
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddScoped<ILLMClient>(sp => sp.GetRequiredService<OpenAICompatibleClient>());
builder.Services.AddScoped<SummaryService>();
Point LLM:BaseUrl at any OpenAI-compatible gateway. If you point it at a gateway like n4n.ai, you get one OpenAI-compatible endpoint that covers 240+ models and automatically falls back when a provider is degraded, so your client code stays single-path.
Store secrets in appsettings.Development.json or environment variables, never in source.
Step 4: Add timeout and resilience guardrails
A bare Timeout is not enough. Wrap the client with a standard resilience pipeline to handle transient 429/5xx responses without custom Polly code.
builder.Services.AddHttpClient<OpenAICompatibleClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["LLM:BaseUrl"]!);
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["LLM:ApiKey"]}");
})
.AddStandardResilienceHandler(); // from Microsoft.Extensions.Http.Resilience
The resilience handler adds retry with jitter, rate limiting, and circuit breaking by default. Your dotnet 8 dependency injection llm client now survives provider blips without taking down the request thread.
Step 5: Consume the client in a domain service
Inject ILLMClient into a scoped service. Keep the prompt construction in the service, not the HTTP client.
public class SummaryService
{
private readonly ILLMClient _client;
public SummaryService(ILLMClient client) => _client = client;
public async Task<string> SummarizeAsync(string text, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(text)) return string.Empty;
var req = new ChatRequest(
Model: "gpt-4o-mini",
Messages: new[]
{
new Message("system", "You summarize technical text in one paragraph."),
new Message("user", text)
});
var completion = await _client.CompleteAsync(req, ct);
return completion.Choices[0].Message.Content.Trim();
}
}
Constructor injection means the dependency graph is explicit. A controller or minimal API endpoint can take SummaryService and call it without knowing an HTTP call happens.
Step 6: Verify the wiring with a local run
Create a minimal endpoint to exercise the stack:
app.MapPost("/summarize", async (SummaryService svc, HttpContext ctx) =>
{
using var reader = new StreamReader(ctx.Request.Body);
var body = await reader.ReadToEndAsync();
var summary = await svc.SummarizeAsync(body, ctx.RequestAborted);
return Results.Ok(new { summary });
});
Run the app and hit it with a curl:
dotnet run --project Api
curl -X POST http://localhost:5000/summarize \
-H "Content-Type: text/plain" \
-d "Dependency injection in .NET 8 uses the built-in IServiceCollection..."
Success looks like a JSON 200 with a summary field and a console log line reporting token usage. If you get a 401, your LLM:ApiKey is wrong. If you get a 404 on v1/chat/completions, your BaseUrl is missing the trailing slash or path.
Step 7: Forward routing directives and cache hints
Some gateways let clients pin a model provider or reuse a prefix cache. Pass those through as headers in the client if your product needs deterministic routing.
httpReq.Headers.Add("X-Route-To", "openai");
httpReq.Headers.Add("Cache-Control", "max-age=3600");
Gateways such as n4n.ai honor client routing directives and forward provider cache-control hints, so the dotnet 8 dependency injection llm client can influence backend selection without hardcoding provider URLs. Keep these headers optional and read them from configuration so dev environments stay simple.
Step 8: Mock the client in unit tests
Because you programmed against ILLMClient, tests need no network.
public class SummaryServiceTests
{
[Fact]
public async Task Returns_content_from_client()
{
var fake = new FakeClient();
var svc = new SummaryService(fake);
var result = await svc.SummarizeAsync("hello");
Assert.Contains("fake", result);
}
class FakeClient : ILLMClient
{
public Task<ChatCompletion> CompleteAsync(ChatRequest r, CancellationToken ct = default)
=> Task.FromResult(new ChatCompletion("id", r.Model,
new Usage(1, 1, 2),
new[] { new Choice(new Message("assistant", "fake summary")) }));
}
}
This proves the DI boundary holds. The same SummaryService runs in production with the HTTP client and in tests with the fake.
Final notes
A dotnet 8 dependency injection llm client should be a thin adapter, not a framework. Use AddHttpClient<T>, keep the interface minimal, and push resilience to the pipeline. When you need multi-provider coverage or per-token metering, a gateway endpoint replaces the BaseUrl without touching your code. That’s the whole pattern: interface, adapter, registration, service, test.