n4nAI

API integration

How-to

Dependency injection for LLM clients in .NET 8

Learn to implement a dotnet 8 dependency injection llm client using OpenAI-compatible endpoints, resilient provider fallbacks, and testable service layers.

n4n Team3 min read603 words

Audio narration

Coming soon — every post will get a voice note here.

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.

Tagsdotnetdependency-injectiondotnet-8llm-client

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →