DOTNET EXPERT BLOG

Understanding IChatClient in .NET: Build Provider-Independent AI Applications | Day 2

9/27/2026 9:44:32 AM Noor All Safaet Loading... 0

In Day 1 of this series, we built our first AI application with C# and .NET. We configured an AI provider, created an IChatClient, sent a prompt, and received an AI-generated response.

That was enough to make the application work.

But one important line deserves much more attention:

IChatClient chatClient =
    new OpenAIClient(apiKey)
        .GetChatClient(model)
        .AsIChatClient();

Why are we using IChatClient instead of using the provider's client directly?

That question is the focus of Day 2.

Understanding IChatClient is important because it becomes one of the main building blocks for AI applications built with Microsoft.Extensions.AI.

Today, we will learn:

  • What IChatClient is

  • Why AI abstractions are useful

  • How IChatClient separates application code from AI providers

  • How prompts are represented using ChatMessage

  • How responses are returned through ChatResponse

  • How ChatOptions controls requests

  • How to organize AI code more cleanly

  • Where IChatClient fits into a real .NET application

By the end, our AI integration will start looking less like a simple experiment and more like application architecture we can continue building on.


Previously in This Series

In Day 1, we created a simple console application and made our first AI request using C#.

The basic flow looked like this:

C# Application
      ↓
IChatClient
      ↓
AI Provider
      ↓
AI Model
      ↓
Response

We intentionally kept the application simple.

Today, instead of adding many new features, we are going to understand the abstraction sitting at the center of that flow.


What Is IChatClient?

IChatClient is an interface from the Microsoft.Extensions.AI ecosystem that provides a common abstraction for interacting with AI services that support chat-style requests.

At a simplified level, we can think of it like this:

public interface IChatClient
{
    // Send messages and receive a response
}

The actual interface provides functionality for sending chat messages, receiving complete responses, streaming responses, accessing underlying services, and working with related AI features.

The important idea is not the number of methods.

The important idea is abstraction.

Instead of allowing our business logic to depend directly on a specific AI provider, our application can depend on:

IChatClient

This is similar to patterns .NET developers already use.

For example:

ILogger

instead of depending on a particular logging provider.

Or:

IConfiguration

instead of knowing where every configuration value comes from.

The same architectural idea applies to AI.

Our application talks to a common interface, while the underlying implementation communicates with the actual AI provider.


Why Do We Need an AI Abstraction?

Imagine that we build our entire application directly around one provider-specific SDK.

Our service might look conceptually like this:

public class ProductDescriptionService
{
    private readonly SomeProviderClient _client;

    public ProductDescriptionService(SomeProviderClient client)
    {
        _client = client;
    }
}

Now imagine we use that client in 20 services.

Later, we decide to:

  • switch providers

  • use a different model

  • use Azure-hosted models

  • add testing with a fake implementation

  • add caching

  • add telemetry

  • introduce middleware

  • route different requests to different models

Our application has become tightly coupled to the original provider.

This is exactly the kind of problem abstractions are designed to reduce.

With IChatClient, our application code can instead depend on:

private readonly IChatClient _chatClient;

The service does not need to know which provider is behind it.

That gives us a much cleaner boundary.


Provider-Specific Client vs IChatClient

Consider the code from Day 1:

IChatClient chatClient =
    new OpenAIClient(apiKey)
        .GetChatClient(model)
        .AsIChatClient();

There are several things happening here.

First:

new OpenAIClient(apiKey)

creates the provider-specific client.

Then:

.GetChatClient(model)

gets a client configured for a particular model.

Finally:

.AsIChatClient()

adapts that provider-specific client to the common IChatClient abstraction.

Conceptually:

Provider SDK
     ↓
Provider Chat Client
     ↓
AsIChatClient()
     ↓
IChatClient
     ↓
Your Application

Your application can now work primarily with IChatClient.

That separation becomes increasingly valuable as the application grows.


Why This Matters in Real Applications

Suppose we are building an AI assistant for an inventory management system.

Eventually it might support questions such as:

Which products are running low?
Summarize today's sales.
Explain why profit decreased this month.
Create a short description for this product.

The business services handling these requests should ideally care about the task, not about the AI provider's SDK.

For example:

public class InventoryAiService
{
    private readonly IChatClient _chatClient;

    public InventoryAiService(IChatClient chatClient)
    {
        _chatClient = chatClient;
    }
}

Now the service depends on the capability it needs:

Chat with an AI model

rather than a specific vendor.

This follows a familiar software-design principle:

Depend on abstractions rather than concrete implementations.


Understanding the Basic IChatClient Request

The simplest request looks like this:

var response = await chatClient.GetResponseAsync(
    "Explain dependency injection in C#.");

Console.WriteLine(response);

This is convenient because the extension API allows us to pass a simple string.

Conceptually, the string represents a user message.

The flow becomes:

"Explain dependency injection in C#."
                ↓
          IChatClient
                ↓
            AI Model
                ↓
          ChatResponse

For quick requests, this is often enough.

But real AI applications usually need more control.

For that, we can work with ChatMessage.


Understanding ChatMessage

A conversation with an AI model is normally made up of messages.

Microsoft.Extensions.AI represents these using ChatMessage.

For example:

using Microsoft.Extensions.AI;

var messages = new List<ChatMessage>
{
    new(
        ChatRole.System,
        "You are a helpful C# programming assistant."),

    new(
        ChatRole.User,
        "Explain dependency injection with a simple example.")
};

Then send them:

var response = await chatClient.GetResponseAsync(messages);

Console.WriteLine(response);

This gives us much more control over the conversation.


Understanding Chat Roles

Messages can have different roles.

Two of the most common are:

ChatRole.System

and:

ChatRole.User

A system message defines instructions or behavior for the model.

For example:

new ChatMessage(
    ChatRole.System,
    "You are a senior .NET developer. Explain concepts clearly and use C# examples.")

A user message contains the actual request:

new ChatMessage(
    ChatRole.User,
    "What is dependency injection?")

Together:

var messages = new List<ChatMessage>
{
    new(
        ChatRole.System,
        "You are a senior .NET developer. Explain concepts clearly and use C# examples."),

    new(
        ChatRole.User,
        "What is dependency injection?")
};

The distinction matters.

Instead of repeating behavioral instructions inside every user prompt, we can establish the expected behavior separately.


A Practical Example

Suppose our application needs an AI feature that explains inventory information in simple language.

We could define:

var messages = new List<ChatMessage>
{
    new(
        ChatRole.System,
        """
        You are an inventory management assistant.
        Explain business information clearly and concisely.
        Do not invent missing values.
        """),

    new(
        ChatRole.User,
        """
        Product: Wireless Mouse
        Current Stock: 4
        Reorder Level: 10

        Explain the stock situation.
        """)
};

Then:

var response =
    await chatClient.GetResponseAsync(messages);

Console.WriteLine(response);

The model now has both:

Behavior instructions
+
Business data
+
User request

This pattern will become very useful later when we connect AI to application data.


Understanding ChatResponse

When we call:

await chatClient.GetResponseAsync(...)

we receive a chat response.

For simple console examples, this works:

Console.WriteLine(response);

But treating the result as structured response data is more useful in larger applications.

For example:

var response =
    await chatClient.GetResponseAsync(messages);

Console.WriteLine(response.Text);

The response abstraction can carry more than just visible text.

Depending on the provider and model, response information may include additional metadata such as usage information, finish information, message content, or provider-specific data.

That is another reason not to design the entire application around converting everything immediately into a plain string.


What Is ChatOptions?

Sometimes we need more control over an AI request.

ChatOptions represents options that can be supplied with a chat request.

For example:

var options = new ChatOptions
{
    Temperature = 0.2f
};

Then:

var response = await chatClient.GetResponseAsync(
    messages,
    options);

Options can influence how a request is processed, depending on what the underlying provider and model support.

You may encounter settings related to areas such as:

  • temperature

  • maximum output tokens

  • tools

  • response formats

  • model selection

  • other provider-supported capabilities

Not every provider or model supports every option in exactly the same way.

That is important.

An abstraction creates a common programming model, but it does not mean every AI provider suddenly has identical capabilities.


Temperature in Simple Terms

Temperature generally influences how deterministic or varied a model's output can be.

For tasks where consistency matters, we may prefer a lower value.

For example:

var options = new ChatOptions
{
    Temperature = 0.1f
};

For more creative tasks, a higher value may sometimes be appropriate.

However, do not treat temperature as a universal "creativity slider."

Different models can interpret or support generation parameters differently.

For business applications, predictable prompts, validation, structured outputs, and application logic are often more important than constantly adjusting temperature.


Passing CancellationToken

Production .NET applications should also think about cancellation.

Many AI requests involve network calls and can take time.

Instead of:

await chatClient.GetResponseAsync(messages);

we can pass a cancellation token:

await chatClient.GetResponseAsync(
    messages,
    cancellationToken: cancellationToken);

For example, inside an ASP.NET Core endpoint or service:

public async Task<string> AskAsync(
    string question,
    CancellationToken cancellationToken)
{
    var response = await _chatClient.GetResponseAsync(
        question,
        cancellationToken: cancellationToken);

    return response.Text;
}

If the HTTP request is cancelled, the cancellation can propagate into the AI operation.

This is a small detail in a tutorial but an important habit for production code.


Don't Scatter IChatClient Calls Everywhere

Technically, we could inject IChatClient into every controller, page, component, or business class that needs AI.

For example:

public class ProductController
{
    private readonly IChatClient _chatClient;
}

Then:

public class ReportController
{
    private readonly IChatClient _chatClient;
}

Then:

public class InventoryController
{
    private readonly IChatClient _chatClient;
}

This can work, but it may gradually spread prompt construction and AI-specific behavior throughout the application.

A better architecture is often to create application-specific AI services.

For example:

Controller
    ↓
InventoryAiService
    ↓
IChatClient
    ↓
AI Provider

Now the rest of the application works with a service designed around business use cases.


Creating Our First AI Service

Create:

Services/
    IAiChatService.cs
    AiChatService.cs

Start with the interface:

public interface IAiChatService
{
    Task<string> AskAsync(
        string prompt,
        CancellationToken cancellationToken = default);
}

Now implement it:

using Microsoft.Extensions.AI;

public class AiChatService : IAiChatService
{
    private readonly IChatClient _chatClient;

    public AiChatService(IChatClient chatClient)
    {
        _chatClient = chatClient;
    }

    public async Task<string> AskAsync(
        string prompt,
        CancellationToken cancellationToken = default)
    {
        var response = await _chatClient.GetResponseAsync(
            prompt,
            cancellationToken: cancellationToken);

        return response.Text;
    }
}

This is a very small service.

But architecturally, something important has changed.

Our application code can now depend on:

IAiChatService

while our AI infrastructure depends on:

IChatClient

Why Create Another Interface?

You may reasonably ask:

IChatClient is already an interface. Why create IAiChatService?

Because they solve different problems.

IChatClient represents a general AI chat capability.

IAiChatService represents something owned by our application.

Today our interface is simple:

Task<string> AskAsync(string prompt);

Later, it could evolve into business-specific operations such as:

Task<string> ExplainStockAsync(...);

Task<string> SummarizeSalesAsync(...);

Task<string> GenerateProductDescriptionAsync(...);

The provider abstraction belongs to the AI integration layer.

The application service represents our application's use cases.

That distinction becomes more valuable as the project grows.


A Better Project Structure

Our tutorial project can gradually move toward this structure:

CSharpAiTutorial
│
├── Services
│   ├── IAiChatService.cs
│   └── AiChatService.cs
│
├── Models
│
├── Configuration
│
└── Program.cs

Later, as the application becomes larger, we can separate responsibilities further.

For example:

Application
│
├── AI
│   ├── Interfaces
│   └── Services
│
Infrastructure
│
└── AI
    ├── Providers
    └── Configuration

We do not need this complexity on Day 2.

The important thing is understanding where the boundaries can go.


IChatClient and Dependency Injection

One of the main advantages of using an abstraction is that it fits naturally into dependency injection.

Instead of manually constructing the AI client everywhere:

var client = new OpenAIClient(apiKey);

we want configuration to happen near the application's composition root.

Then services can simply request:

IChatClient

through constructor injection.

Conceptually:

public class AiChatService
{
    private readonly IChatClient _chatClient;

    public AiChatService(IChatClient chatClient)
    {
        _chatClient = chatClient;
    }
}

The service does not need to know:

  • where the API key came from

  • how the provider client was created

  • which SDK initialized it

  • where configuration is stored

Those are infrastructure concerns.

This separation is one of the biggest reasons IChatClient becomes useful in larger applications.


Why Provider Independence Matters

Consider this service:

public class AiChatService
{
    private readonly IChatClient _chatClient;

    public AiChatService(IChatClient chatClient)
    {
        _chatClient = chatClient;
    }
}

Notice what is missing.

There is no:

OpenAIClient

inside the service.

There is no provider-specific API key logic.

There is no provider-specific configuration code.

The service only knows:

IChatClient

This makes it easier to evolve the infrastructure without rewriting business logic.

For example, we might later change:

OpenAI

to another implementation that exposes the same abstraction.

The exact configuration will still need to change, and provider capabilities may differ, but much of the consuming application code can remain unchanged.

That is what provider independence means in practice.

It is not that all providers are identical.

It means our application is less tightly coupled to them.


IChatClient Also Helps Testing

Suppose we have business logic that depends directly on an external AI API.

Running tests may require:

  • internet access

  • valid credentials

  • API usage

  • waiting for remote responses

  • handling nondeterministic output

That is inconvenient for ordinary unit tests.

Because our code works through abstractions, we can design tests around controlled implementations instead.

For example, our application-level interface can easily be replaced:

public class FakeAiChatService : IAiChatService
{
    public Task<string> AskAsync(
        string prompt,
        CancellationToken cancellationToken = default)
    {
        return Task.FromResult(
            "This is a test response.");
    }
}

Now a test does not need to call a real model.

As our tutorial progresses, we will explore testing AI-enabled applications more carefully.

The important lesson today is that good abstractions give us options.


Complete Day 2 Example

Here is a simplified version combining the ideas from today.

IAiChatService.cs

public interface IAiChatService
{
    Task<string> AskAsync(
        string prompt,
        CancellationToken cancellationToken = default);
}

AiChatService.cs

using Microsoft.Extensions.AI;

public class AiChatService : IAiChatService
{
    private readonly IChatClient _chatClient;

    public AiChatService(IChatClient chatClient)
    {
        _chatClient = chatClient;
    }

    public async Task<string> AskAsync(
        string prompt,
        CancellationToken cancellationToken = default)
    {
        var messages = new List<ChatMessage>
        {
            new(
                ChatRole.System,
                """
                You are a helpful .NET programming assistant.
                Give clear, concise, and technically accurate answers.
                Use C# examples when useful.
                """),

            new(
                ChatRole.User,
                prompt)
        };

        var response = await _chatClient.GetResponseAsync(
            messages,
            cancellationToken: cancellationToken);

        return response.Text;
    }
}

Our calling code becomes very simple:

string answer = await aiChatService.AskAsync(
    "Explain the repository pattern in ASP.NET Core.");

Console.WriteLine(answer);

The calling code does not care which model produced the response.

That is exactly the separation we want.


What Happens Behind the Scenes?

When this runs:

await _chatClient.GetResponseAsync(messages);

we can think about the flow like this:

Application
     ↓
IAiChatService
     ↓
AiChatService
     ↓
IChatClient
     ↓
Provider Adapter
     ↓
Provider SDK
     ↓
AI Model
     ↓
ChatResponse
     ↓
Application

Each layer has a different responsibility.

Application

Decides what the user wants to do.

AI Service

Creates the appropriate instructions and messages.

IChatClient

Provides the common AI chat abstraction.

Provider Adapter

Connects the common abstraction to the specific provider.

Provider SDK

Handles communication with the provider.

AI Model

Processes the input and generates a response.

This mental model will make later topics much easier to understand.


Common Mistake: Mixing Business Logic with Prompts

Consider this:

public async Task<IActionResult> Product(int id)
{
    var product = await _repository.GetAsync(id);

    var prompt =
        $"Product: {product.Name}, Stock: {product.Stock}. " +
        "Explain whether stock is low.";

    var response =
        await _chatClient.GetResponseAsync(prompt);

    return View(response);
}

For a tiny demo, this may be acceptable.

But as the application grows, controllers can become responsible for:

  • database access

  • prompt construction

  • AI calls

  • validation

  • response formatting

  • business decisions

That quickly becomes difficult to maintain.

Instead:

Controller
    ↓
Application Service
    ↓
AI Service
    ↓
IChatClient

This keeps AI behavior easier to test and change.


Common Mistake: Treating AI as Business Logic

An AI response should not automatically become a business decision.

For example, imagine the model says:

This product should be reordered immediately.

Your application should not blindly create a purchase order based only on that text.

Business rules should still be implemented and validated by application code.

For example:

bool shouldReorder =
    product.Stock <= product.ReorderLevel;

AI can help explain the result:

Current stock is below the configured reorder level.
Consider replenishing this product.

But deterministic business rules should remain deterministic when possible.

This distinction becomes especially important when AI applications begin calling tools or modifying data.


Common Mistake: Assuming Every Provider Behaves the Same

IChatClient gives us a common abstraction.

It does not guarantee identical behavior across every provider and model.

Models can differ in:

  • context limits

  • tool support

  • structured output capabilities

  • multimodal support

  • reasoning behavior

  • generation parameters

  • latency

  • pricing

  • response metadata

Therefore, provider independence should not be interpreted as:

Every model is interchangeable with zero testing.

A better interpretation is:

Our application has a stable integration boundary.

We still need to test the models and providers we choose.


Common Mistake: Reusing Mutable Request Objects Carelessly

As AI applications become concurrent, avoid assuming every request object can safely be shared and modified across multiple operations.

For example, rather than creating one mutable ChatOptions instance and changing it for many simultaneous requests, prefer creating request-specific options when appropriate:

var options = new ChatOptions
{
    Temperature = 0.2f
};

var response = await _chatClient.GetResponseAsync(
    messages,
    options);

This keeps request configuration easier to reason about.

Concurrency becomes especially important when we move from a console application to ASP.NET Core, where many requests can execute at the same time.


Security Still Matters

Using IChatClient does not automatically make an AI application secure.

Your application still needs to consider:

  • prompt injection

  • sensitive business data

  • personal information

  • API key protection

  • authorization

  • output validation

  • tool permissions

  • request size

  • rate limits

  • logging of sensitive prompts

Never assume that because an AI SDK provides an abstraction, application-level security has been handled automatically.

For example, avoid sending unnecessary secrets:

Database passwords
API keys
Access tokens
Private customer information

Only send information the model actually needs.


IChatClient Is More Than GetResponseAsync

Today we are mainly using:

GetResponseAsync(...)

But IChatClient is designed for more than simple one-shot text requests.

The abstraction also supports capabilities that become useful as our project evolves.

For example, we will eventually work with:

Streaming responses
Structured output
Function calling
Conversation history
Middleware
Telemetry
Caching
AI tools

The important part is that we do not need a completely different architecture every time we add one of these features.

We can continue building around the same AI abstraction.


Why Microsoft.Extensions.AI Fits Naturally into .NET

For experienced .NET developers, one of the strengths of Microsoft.Extensions.AI is that the architecture feels familiar.

We already build applications around concepts such as:

Dependency Injection
Configuration
Logging
Options
Middleware
Interfaces
Service abstractions
Telemetry

AI does not need to become an isolated part of the application with completely different architectural rules.

Instead, we can integrate it into normal .NET application design.

Conceptually:

ASP.NET Core Application
        │
        ├── Controllers / Endpoints
        │
        ├── Application Services
        │
        ├── Data Access
        │
        └── AI Services
                ↓
            IChatClient
                ↓
           AI Provider

This is much easier to maintain than scattering provider-specific AI calls throughout the application.


When Should You Use the Provider SDK Directly?

Does using IChatClient mean we should never use a provider-specific SDK?

No.

Sometimes a provider exposes a feature that is:

  • unique to that provider

  • not represented by the common abstraction

  • newly released

  • highly specialized

In that case, using the provider-specific SDK may be reasonable.

The goal is not abstraction for the sake of abstraction.

The goal is to keep common AI functionality behind a stable boundary while still allowing provider-specific capabilities when the application genuinely needs them.

A practical rule is:

Use the common abstraction for common capabilities.

Use provider-specific APIs when you actually need provider-specific features.

What We Built Today

We started Day 2 with this:

IChatClient chatClient

Now we understand why that interface matters.

We learned that IChatClient provides a common abstraction between our .NET application and an AI provider.

We also worked with:

IChatClient
ChatMessage
ChatRole
ChatResponse
ChatOptions
CancellationToken

And we introduced an application-level service:

IAiChatService

with:

AiChatService

Our architecture is beginning to look like this:

Application
      ↓
IAiChatService
      ↓
AiChatService
      ↓
IChatClient
      ↓
AI Provider

This gives us a strong foundation for the rest of the series.


Day 2 Checklist

Before moving to Day 3, make sure you understand these concepts:

  • What IChatClient represents

  • Why provider abstraction is useful

  • How .AsIChatClient() connects a provider client to the abstraction

  • What ChatMessage represents

  • The difference between system and user messages

  • What ChatResponse represents

  • What ChatOptions is used for

  • Why cancellation matters

  • Why AI calls should not be scattered throughout the application

  • Why provider independence does not mean every model behaves identically

If these concepts are clear, the next step will feel natural.


What's Next: Day 3

Today we focused on understanding the abstraction.

In Day 3, we will move our AI integration into an ASP.NET Core application and build a reusable AI chat service using dependency injection.

We will learn how to:

  • register IChatClient with dependency injection

  • configure the AI provider centrally

  • inject AI services into application classes

  • create a reusable chat service

  • expose AI functionality through an ASP.NET Core endpoint

  • keep API keys and provider configuration outside business logic

  • organize the project for future AI features

The architecture will evolve from:

Console Application
      ↓
IChatClient

into:

ASP.NET Core
      ↓
Application Service
      ↓
IChatClient
      ↓
AI Provider

That will give us the foundation for conversation memory, streaming, structured output, function calling, RAG, and eventually AI agents.


Final Thoughts

Sending a prompt to an AI model is easy.

Designing an application that can continue growing after the first prompt is the more important challenge.

IChatClient gives .NET developers a useful boundary between application code and AI infrastructure.

Instead of building the application around a specific model provider, we can build around the capability our application needs:

Chat with an AI model.

The provider becomes an implementation detail behind that capability.

That does not eliminate provider differences, security concerns, testing, or good architecture. But it gives us a cleaner place to manage them.

On Day 1, we proved that our C# application could talk to an AI model.

On Day 2, we gave that integration a proper architectural boundary.

In Day 3, we will put that boundary to work inside ASP.NET Core.

Comments 0