Table of Contents
An IChatClient is expected to support concurrent requests unless its implementation documents otherwise. A tool bound to one user’s database context cannot safely follow it into a singleton registration.
I would check that boundary before adding more registrations. A tool delegate can quietly retain a caller’s state for as long as the object that holds it.
This article assumes you already know the .NET DI container. It uses Microsoft.Extensions.AI to show where chat clients, prompts, retrieval, and tools belong in the dependency graph. The lifetime decisions also apply when an agent framework sits above the client.
Start with the objects that hold state
Consider a support assistant with a shared model connection and an order lookup tool. The lookup reads through a scoped service that knows which orders the current caller may access.
If startup code captures that service in a tool delegate and saves the tool on a singleton, the delegate retains a reference to the scoped service. If the service came from a temporary scope, later calls can reach it after that scope has disposed its disposable services. Holding a reference does not prevent that disposal.
The intended graph is small:
Application lifetime
IChatClient pipeline
immutable prompt templates
Request or job scope
support service
-> shared IChatClient
-> authorized retrieval / order lookup
One model operation
fresh messages
fresh ChatOptions
tools bound to this scope
The service-layer article explains which behavior belongs behind the support feature. Once those dependencies exist, each needs an owner and a lifetime.
| Component | Starting lifetime | What changes the decision |
|---|---|---|
| Chat client and shared middleware | Singleton | Every component must support concurrent use and avoid retaining caller state. |
| Fixed prompt template | Constant or immutable singleton | A renderer that depends on scoped services or retains scoped state must follow that scope. |
| Application service | Scoped when it uses scoped capabilities | A stateless service with suitable dependencies can be transient. |
| Retrieval or tool that depends on scoped caller or database state | Scoped | Follow the lifetime and concurrency limits of those dependencies. |
| Messages, call options, bound tool list | Local to one operation | Conversation history needs explicit storage and ownership across operations. |
IChatClient expects concurrent use unless an implementation says otherwise. Its arguments have a different contract: implementations may mutate messages or options. Allocate those per operation. The interface remarks describe both points.
Register one client at the composition root
The following sample uses .NET 10, Microsoft.Extensions.Hosting 10.0.11, Microsoft.Extensions.AI 10.9.0, and OllamaSharp 5.4.30. Ollama keeps credentials out of this local example. Wire a hosted provider’s adapter and authentication at the composition root. The client factory can resolve a separately registered application-wide credential.
Create a console project:
dotnet new console -n AiComposition -f net10.0
cd AiComposition
dotnet add package Microsoft.Extensions.Hosting --version 10.0.11
dotnet add package Microsoft.Extensions.AI --version 10.9.0
dotnet add package OllamaSharp --version 5.4.30
Replace Program.cs with this startup code, then append the types from the next two sections:
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using OllamaSharp;
using IHost host = Host.CreateDefaultBuilder(args)
.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
})
.ConfigureServices((context, services) =>
{
services.AddOptions<SupportModelOptions>()
.Bind(context.Configuration.GetSection("AI:Support"))
.Validate(o => Uri.TryCreate(
o.Endpoint, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https"),
"AI:Support:Endpoint must be an HTTP(S) URI.")
.Validate(o => !string.IsNullOrWhiteSpace(o.Model),
"AI:Support:Model is required.")
.ValidateOnStart();
services.AddSingleton<IChatClient>(sp =>
{
var settings = sp
.GetRequiredService<IOptions<SupportModelOptions>>()
.Value;
IChatClient client = new OllamaApiClient(
new Uri(settings.Endpoint), settings.Model);
return client.AsBuilder()
.UseFunctionInvocation()
.Build();
});
services.AddScoped<IOrderStatusReader, DemoOrderStatusReader>();
services.AddScoped<SupportAssistant>();
})
.Build();
await host.StartAsync();
await using (var scope = host.Services.CreateAsyncScope())
{
var assistant = scope.ServiceProvider
.GetRequiredService<SupportAssistant>();
Console.WriteLine("AI service graph resolved.");
if (args.Contains("--ask"))
{
using var timeout = new CancellationTokenSource(
TimeSpan.FromSeconds(30));
Console.WriteLine(await assistant.AnswerAsync(
"What is the status of order DEMO-42?",
timeout.Token));
}
}
await host.StopAsync();
public sealed class SupportModelOptions
{
public string Endpoint { get; set; } = "";
public string Model { get; set; } = "";
}
The factory constructs the complete client pipeline. It resolves application-wide settings, but never an order reader or current user. UseFunctionInvocation supplies the execution mechanism. The feature supplies tools for each call. Microsoft’s tool-calling example shows this same separation between the pipeline and call options.
I use an explicit singleton factory to make ownership visible. AddChatClient is also available when you want the library’s DI builder API.
ValidateOnStart checks the configured values when the host starts. It does not prove that the endpoint is reachable or that the model supports tools. The options documentation explains that validation lifecycle. This client reads its settings once. Changing configuration later will not rebuild it. Use a deliberate replacement strategy if you need live model changes.
Bind tools inside the operation
Append this service to Program.cs:
public sealed class SupportAssistant(
IChatClient chatClient,
IOrderStatusReader orders)
{
private const string Instructions = """
Help with order status. Use lookup_order_status for order facts.
If the lookup returns no result, say the status is unavailable.
Do not invent a status.
""";
public async Task<string> AnswerAsync(
string question,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(question);
List<ChatMessage> messages =
[
new(ChatRole.System, Instructions),
new(ChatRole.User, question)
];
ChatOptions options = new()
{
Tools =
[
AIFunctionFactory.Create(
orders.FindVisibleAsync,
name: "lookup_order_status",
description: "Look up an order status by order number.")
]
};
ChatResponse response = await chatClient.GetResponseAsync(
messages, options, cancellationToken);
return response.Text;
}
}
The method creates a tool bound to this scope’s orders instance. The caller awaits the entire operation before disposing its scope, so the tool can use its scoped dependencies until the call completes. A streaming implementation must defer scope disposal until enumeration finishes too.
The fixed instructions need no DI registration. If several features share an immutable template catalog, registering it as a singleton can be reasonable. Rendered messages still belong to the operation. Putting them on the catalog would mix one caller’s content with another’s.
A scoped tool is not automatically safe for parallel invocation. For example, two tools in one scope could share the same EF Core DbContext. Keep their execution sequential or give each parallel operation its own correctly initialized scope and context. FunctionInvokingChatClient.AllowConcurrentInvocation defaults to false, so the middleware executes function calls within a request sequentially. Separate requests can still invoke their tools concurrently, as the concurrency remarks explain.
This sample returns plain text to keep the lifetime example readable. A real support feature still needs response acceptance rules and a tool-loop budget. The prompt cannot enforce either requirement.
Give retrieval and tools their application dependencies
Append a small lookup contract and a demo implementation:
public interface IOrderStatusReader
{
Task<string?> FindVisibleAsync(
string orderNumber,
CancellationToken cancellationToken);
}
public sealed class DemoOrderStatusReader : IOrderStatusReader
{
public Task<string?> FindVisibleAsync(
string orderNumber,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult<string?>(
orderNumber == "DEMO-42" ? "Dispatched" : null);
}
}
The demo contains one public fixture and has no authentication or database. In an application, replace it with a service that checks the authenticated caller’s access before loading an order. Keep that service scoped when it depends on scoped caller context or a scoped database context. The tool accepts an order number. Trusted application context supplies identity and tenant scope.
Register an authorized retrieval service the same way when it depends on a scoped database context or caller context. Its lower-level vector client or embedding generator may be reusable across scopes if the implementation supports that. A shared connection does not require a shared authorization context. Database access alone does not dictate a scoped lifetime: a service can use IDbContextFactory<TContext> to create and dispose a context for each operation. Its own lifetime still depends on the other state and dependencies it holds.
Scope also has a precise limit: in ASP.NET Core, it normally means one HTTP request. It does not mean one conversation. Store conversation history under an authorized conversation ID and load the allowed messages for each operation. A scoped List<ChatMessage> will disappear with the request. A singleton list will be shared across callers.
Let the owner dispose the client
The DI container owns the client returned by the singleton factory. At shutdown, disposing the outer pipeline also disposes its inner client. Consumers should await their work and leave disposal to that owner. See the .NET disposal guidance and DelegatingChatClient.Dispose.
Do not put an injected IChatClient in a using block inside AnswerAsync. That would dispose the shared instance while other requests may still need it. For the same reason, avoid separately registering and owning the inner client when a disposing wrapper already owns it.
Creating a scope around a tool delegate and then returning the delegate does not transfer ownership. The delegate still refers to the scoped instance after disposal. Keep the operation inside the scope instead.
A background worker needs to create that scope explicitly. Hosted services have no automatic per-job scope. Resolve the feature inside a fresh async scope for each job and await completion before disposing it, as in Microsoft’s scoped worker example. A new scope does not supply an HTTP user. Rebuild execution context from validated job identity data and re-evaluate authorization where permissions may have changed since enqueueing. Stored identity alone does not establish current permission.
Check composition without asking a model
Set the endpoint and model through configuration:
export AI__Support__Endpoint=http://localhost:11434
export AI__Support__Model=llama3.1
dotnet run
Without --ask, the sample starts the host and resolves the scoped feature. It constructs the client without sending a model request. You should see AI service graph resolved. among the host logs.
Remove the model value and startup should fail options validation. Temporarily register SupportAssistant as a singleton and scope validation should reject its scoped order reader. These checks answer useful composition questions before a provider enters the picture.
ValidateOnBuild cannot inspect arbitrary code inside registration factories. Resolve the feature graph as well, as the sample does, and test any factory branches your application uses. Avoid calling BuildServiceProvider inside registration code to get dependencies early. Use the provider passed to the registration factory.
For an optional live check, run Ollama locally, pull llama3.1 (or whatever model you like), then run dotnet run -- --ask. That exercises the model path. If the model is missing, Ollama returns a 404 error. This sample does not pull it automatically. The live check is separate from the composition check and does not establish answer quality.
When adding a second client, choose it explicitly at the feature boundary with a keyed registration or a typed adapter. Keep keys fixed in composition code. A user-supplied string should not choose a privileged model connection.
When to use this structure
Use a shared client with scoped application capabilities when an AI feature reads caller-specific data or exposes tools backed by scoped services. Build fresh messages and tool bindings for every operation, and dispose the scope only after all work finishes.
Before shipping, trace one tool delegate back to its target object. Check who owns that object, when it is disposed, and whether two invocations can reach it concurrently. That small review catches problems a successful chat response will never reveal.
When not to add more DI machinery
A short console experiment can construct and dispose its client directly. A fixed prompt can remain a constant. Add a factory, interface, or registration extension when it owns a real variation or lifecycle decision.
If the lifetime is hard to choose, look at the fields and captured delegates. A stored message list or a reference to the current caller often explains why an otherwise reusable component cannot be shared.
Related reading
- Designing an AI Service Layer
- Tools and Dependency Injection in Microsoft Agent Framework
- Provider Independence from Day One
Sources
- Microsoft Learn:
IChatClientconcurrency and argument-mutation contract - Microsoft Learn: Tool calling with
IChatClient - Microsoft Learn:
FunctionInvokingChatClientand tool concurrency - Microsoft Learn: Dependency injection and disposal ownership
- Microsoft Learn:
DelegatingChatClient.Dispose - Microsoft Learn: Options validation and
ValidateOnStart - Microsoft Learn: Using scoped services in a background worker
- Microsoft Learn: Creating EF Core contexts with
IDbContextFactory - Ollama: API errors, including missing models
- NuGet:
Microsoft.Extensions.Hosting10.0.11