DOTNET EXPERT BLOG

Build a Reusable AI Chat Service in ASP.NET Core with Dependency Injection | Day 3

9/28/2026 5:06:20 AM Noor All Safaet Loading... 0

In the first two days of this series, we built the foundation of our C# AI application.

On Day 1, we connected a .NET application to an AI model and sent our first prompt.

On Day 2, we explored IChatClient and learned why a provider-independent abstraction is useful when building maintainable AI applications.

Today, we will take the next important step.

Instead of calling an AI model directly from a console application, we will integrate AI into an ASP.NET Core Web API using dependency injection and a reusable application service.

By the end of Day 3, our application will follow this flow:

HTTP Request
     ↓
ASP.NET Core Endpoint
     ↓
IAiChatService
     ↓
AiChatService
     ↓
IChatClient
     ↓
AI Provider
     ↓
AI Model

This gives us a strong foundation for later features such as conversation memory, streaming responses, structured output, function calling, RAG, and AI agents.


What We Will Build Today

We will create a simple ASP.NET Core Web API endpoint:

POST /api/ai/chat

A client can send:

{
  "message": "Explain dependency injection in ASP.NET Core."
}

Our application will send the question to the AI model and return the generated answer:

{
  "answer": "Dependency injection is..."
}

Making the AI request itself is straightforward.

The more important goal today is organizing the application correctly.

Instead of putting provider configuration directly inside a controller:

Controller
    ↓
Provider SDK
    ↓
AI Model

we will separate HTTP handling, application behavior, and AI infrastructure.


Why Move to ASP.NET Core?

A console application was ideal for learning the fundamentals.

Real applications usually need to expose AI functionality through something such as:

  • Web APIs

  • Blazor applications

  • MVC applications

  • SaaS platforms

  • internal business systems

  • mobile application backends

  • inventory systems

  • CRM applications

  • ERP systems

ASP.NET Core already provides infrastructure for:

Dependency Injection
Configuration
Logging
Authentication
Authorization
Middleware
HTTP APIs
Rate Limiting
Options
Health Checks

AI therefore does not need to exist as a separate architectural world.

We can integrate it into normal .NET application patterns.


Prerequisites

Before starting Day 3, you should have:

  • basic C# knowledge

  • basic ASP.NET Core knowledge

  • .NET 8 SDK or later

  • Visual Studio, Visual Studio Code, or another C# IDE

  • access to an AI provider

  • an API key

  • access to an appropriate chat model

  • a basic understanding of IChatClient

If you have not completed the previous tutorials, start with:

Day 1: Build Your First AI Application with C# and .NET

Day 2: Understanding IChatClient in .NET

You do not need experience with machine learning, Python, TensorFlow, PyTorch, model training, or GPU programming.

We are integrating an existing AI model into a .NET application.


Step 1: Create the ASP.NET Core Web API

Create a new project:

dotnet new webapi -n CSharpAiApi

Move into the project:

cd CSharpAiApi

You now have a standard ASP.NET Core Web API project.

This API will become the backend for the AI features we build throughout the series.


Step 2: Install the Required Packages

We need Microsoft.Extensions.AI for the common AI abstractions.

For the OpenAI integration used in this tutorial:

dotnet add package Microsoft.Extensions.AI
dotnet add package Microsoft.Extensions.AI.OpenAI --prerelease
dotnet add package OpenAI

Package versions and prerelease requirements can change over time, so verify the current package status when using this code in a production project.

The important architectural relationship is:

Microsoft.Extensions.AI
        ↓
IChatClient
        ↓
Provider Integration
        ↓
Provider SDK

Most of our application code will work with IChatClient rather than provider-specific types.


Step 3: Store AI Configuration Securely

Never put a real API key directly into source code.

Avoid:

string apiKey = "sk-your-real-key";

Hardcoded credentials can accidentally appear in:

  • Git repositories

  • source-control history

  • screenshots

  • logs

  • shared code

  • deployment packages

For local development, use .NET User Secrets.

Initialize User Secrets:

dotnet user-secrets init

Store the API key:

dotnet user-secrets set "AI:ApiKey" "YOUR_API_KEY"

Store the model name:

dotnet user-secrets set "AI:Model" "YOUR_MODEL_NAME"

Conceptually, our configuration now contains:

{
  "AI": {
    "ApiKey": "...",
    "Model": "..."
  }
}

The actual secret does not need to live in the application's source code.

For deployed applications, use an appropriate secret-management solution for your hosting environment rather than relying on development User Secrets.


Step 4: Register IChatClient

Open Program.cs.

Add the required namespaces:

using Microsoft.Extensions.AI;
using OpenAI;

Read the configuration:

string apiKey = builder.Configuration["AI:ApiKey"]
    ?? throw new InvalidOperationException(
        "AI:ApiKey is not configured.");

string model = builder.Configuration["AI:Model"]
    ?? throw new InvalidOperationException(
        "AI:Model is not configured.");

Now register the AI client:

builder.Services.AddChatClient(
    new OpenAIClient(apiKey)
        .GetChatClient(model)
        .AsIChatClient());

ASP.NET Core can now resolve IChatClient through dependency injection.

Instead of manually creating a provider client in every class that needs AI, we configure it centrally.

Conceptually:

ASP.NET Core DI
       ↓
IChatClient
       ↓
Provider Adapter
       ↓
AI Provider

This keeps provider configuration near the application's composition root instead of spreading it throughout the codebase.


Step 5: Create the Application AI Service

Create a folder:

Services

Then create:

IAiChatService.cs

Add:

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

This interface represents an AI capability owned by our application.

Notice what it does not expose:

OpenAIClient
API keys
Model configuration
Provider-specific types

The application only knows that it can ask the service a question.


Step 6: Implement AiChatService

Create:

Services/AiChatService.cs

Add:

using Microsoft.Extensions.AI;

public sealed class AiChatService : IAiChatService
{
    private const string SystemPrompt =
        """
        You are a helpful AI assistant.
        Give clear, concise, and accurate answers.
        """;

    private readonly IChatClient _chatClient;
    private readonly ILogger<AiChatService> _logger;

    public AiChatService(
        IChatClient chatClient,
        ILogger<AiChatService> logger)
    {
        _chatClient = chatClient;
        _logger = logger;
    }

    public async Task<string> AskAsync(
        string message,
        CancellationToken cancellationToken = default)
    {
        var messages = new List<ChatMessage>
        {
            new(
                ChatRole.System,
                SystemPrompt),

            new(
                ChatRole.User,
                message)
        };

        try
        {
            _logger.LogInformation(
                "Sending AI chat request.");

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

            return response.Text;
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex,
                "AI chat request failed.");

            throw;
        }
    }
}

Several important decisions are visible here.

The service depends on:

IChatClient

rather than OpenAIClient.

Provider configuration therefore stays outside the application service.

We also inject:

ILogger<AiChatService>

so operational failures can be logged without making logging another global dependency.

Finally, the system prompt has a single home instead of being repeated throughout the application.


Why Have Both IChatClient and IAiChatService?

At first, this may look like two interfaces doing the same thing.

They are not.

IChatClient represents a general capability:

Communicate with a chat-capable AI model.

IAiChatService represents an application capability:

Allow this application to ask its configured AI service a question.

The distinction becomes clearer as the application grows.

Later we might create:

IInventoryAiService

with methods such as:

Task<string> ExplainStockAsync(...);

or:

ISalesAiService

with:

Task<string> SummarizeSalesAsync(...);

Those interfaces describe business use cases.

IChatClient remains the lower-level AI communication abstraction underneath them.


Step 7: Register the Application Service

Return to Program.cs.

Add:

builder.Services.AddScoped<IAiChatService, AiChatService>();

Now ASP.NET Core can resolve our complete dependency chain.

When a controller asks for:

IAiChatService

ASP.NET Core creates AiChatService.

When AiChatService asks for:

IChatClient

ASP.NET Core supplies the registered AI client.

This keeps object construction outside our business and HTTP code.


Step 8: Create the Request Model

Create:

Models/ChatRequest.cs

Instead of manually checking every field in the controller, we can use ASP.NET Core model validation.

Add:

using System.ComponentModel.DataAnnotations;

public sealed class ChatRequest
{
    [Required]
    [StringLength(
        4000,
        MinimumLength = 1,
        ErrorMessage = "Message must contain between 1 and 4000 characters.")]
    public string Message { get; set; } = string.Empty;
}

Now the request has an explicit contract.

A valid request looks like:

{
  "message": "Explain dependency injection in ASP.NET Core."
}

The [Required] attribute prevents a missing value, while [StringLength] gives us a basic application-level limit.

The exact maximum length should eventually be chosen based on your application's requirements and model limits.


Why Validate AI Input?

Input validation is especially important for AI endpoints.

Large or uncontrolled input can affect:

  • token usage

  • API cost

  • latency

  • provider limits

  • application stability

  • abuse risk

AI prompts are still external input.

They should be validated like any other API input.

Validation will not solve every AI security problem, but it gives us a basic boundary before a request reaches a paid external service.


Step 9: Create the Response Model

Create:

Models/ChatResponse.cs

Add:

public sealed class ChatResponse
{
    public string Answer { get; set; } = string.Empty;
}

Our endpoint can now return:

{
  "answer": "Dependency injection is..."
}

Why not return a plain string?

Because API contracts tend to grow.

Later we may need:

{
  "answer": "...",
  "conversationId": "...",
  "model": "...",
  "citations": [],
  "usage": {}
}

Starting with a response object gives us room to evolve without redesigning the endpoint immediately.


Step 10: Create the AI Controller

Create:

Controllers/AiController.cs

Add:

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public sealed class AiController : ControllerBase
{
    private readonly IAiChatService _aiChatService;

    public AiController(
        IAiChatService aiChatService)
    {
        _aiChatService = aiChatService;
    }

    [HttpPost("chat")]
    public async Task<ActionResult<ChatResponse>> ChatAsync(
        [FromBody] ChatRequest request,
        CancellationToken cancellationToken)
    {
        string answer =
            await _aiChatService.AskAsync(
                request.Message,
                cancellationToken);

        return Ok(new ChatResponse
        {
            Answer = answer
        });
    }
}

Because the controller uses [ApiController], ASP.NET Core can automatically handle common model-validation failures before the action executes.

The controller therefore stays focused on HTTP responsibilities:

Receive request
      ↓
Call application service
      ↓
Return response

It does not need to know how the AI provider is configured.


Step 11: Complete Program.cs

A simplified Program.cs now looks like this:

using Microsoft.Extensions.AI;
using OpenAI;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

string apiKey = builder.Configuration["AI:ApiKey"]
    ?? throw new InvalidOperationException(
        "AI:ApiKey is not configured.");

string model = builder.Configuration["AI:Model"]
    ?? throw new InvalidOperationException(
        "AI:Model is not configured.");

builder.Services.AddChatClient(
    new OpenAIClient(apiKey)
        .GetChatClient(model)
        .AsIChatClient());

builder.Services.AddScoped<IAiChatService, AiChatService>();

var app = builder.Build();

app.UseHttpsRedirection();

app.MapControllers();

app.Run();

At this point, the basic AI API is complete.


Step 12: Run the Application

Start the application:

dotnet run

ASP.NET Core will display the configured development URLs.

For example:

https://localhost:7001

The actual port can be different on your machine.

Our endpoint is:

POST /api/ai/chat

Send:

{
  "message": "Explain the repository pattern in ASP.NET Core."
}

The response should look similar to:

{
  "answer": "The repository pattern provides an abstraction..."
}

The exact text will vary depending on the model.


Step 13: Test the Endpoint with Swagger or OpenAPI

ASP.NET Core Web API projects commonly expose OpenAPI support during development, depending on the project template and configuration you are using.

If your project includes an interactive API UI such as Swagger UI, run the application and open its API documentation page.

Locate:

POST /api/ai/chat

Use a request such as:

{
  "message": "What is middleware in ASP.NET Core?"
}

Execute the request and inspect the response.

This is convenient because you can test the API without first building a frontend application.

If your template exposes only an OpenAPI document and not Swagger UI, you can use that document with an API client or add an interactive UI separately.

The exact OpenAPI setup varies between ASP.NET Core versions and project templates, so it is better not to assume that every new project exposes the same /swagger page automatically.


Test with cURL

You can also test the endpoint directly from a terminal:

curl -X POST "https://localhost:7001/api/ai/chat" \
  -H "Content-Type: application/json" \
  -d "{\"message\":\"Explain dependency injection in simple terms.\"}"

Replace the port with the one shown by your application.

A successful response should look conceptually like:

{
  "answer": "Dependency injection is a design technique..."
}

What Happens When a Request Arrives?

Now that all pieces are connected, consider what happens when a client sends:

POST /api/ai/chat

ASP.NET Core binds the JSON body to:

ChatRequest

Model validation runs.

The controller calls:

IAiChatService.AskAsync(...)

AiChatService creates the chat messages and calls:

IChatClient.GetResponseAsync(...)

The provider communicates with the model.

The result travels back through the service and controller as:

ChatResponse

This separation lets each layer focus on a specific responsibility.


Why Not Inject IChatClient Directly into the Controller?

Technically, this works:

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

For a tiny prototype, that may be enough.

But controllers can then gradually become responsible for:

  • prompt construction

  • system instructions

  • model options

  • AI-specific validation

  • response processing

  • business context

A dedicated application service gives those responsibilities a better home.

It also allows the same AI functionality to be reused by:

  • MVC controllers

  • Minimal APIs

  • Blazor components

  • background services

  • scheduled jobs

  • message consumers

without duplicating prompt and AI logic.


Generic AI Service vs Business-Specific AI Service

Our current:

IAiChatService

is intentionally generic because we are still building the tutorial foundation.

It works well for:

  • learning

  • generic chat

  • prototypes

  • basic assistant functionality

Real business applications often benefit from more specific services.

Imagine an inventory application.

Instead of:

AskAsync(string message)

we could define:

public interface IInventoryAiService
{
    Task<string> ExplainStockAsync(
        string productName,
        decimal currentStock,
        decimal reorderLevel,
        CancellationToken cancellationToken = default);
}

Implementation:

using Microsoft.Extensions.AI;

public sealed class InventoryAiService
    : IInventoryAiService
{
    private readonly IChatClient _chatClient;

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

    public async Task<string> ExplainStockAsync(
        string productName,
        decimal currentStock,
        decimal reorderLevel,
        CancellationToken cancellationToken = default)
    {
        var messages = new List<ChatMessage>
        {
            new(
                ChatRole.System,
                """
                You are an inventory management assistant.
                Explain inventory information clearly.
                Do not invent missing business data.
                """),

            new(
                ChatRole.User,
                $"""
                Product: {productName}
                Current Stock: {currentStock}
                Reorder Level: {reorderLevel}

                Explain the current stock situation.
                """)
        };

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

        return response.Text;
    }
}

Now the method represents a real application capability.

This is often a better long-term direction than creating one giant AI service responsible for every AI feature in the system.


Cancellation Matters

Notice how the controller receives:

CancellationToken cancellationToken

and passes it through the service to:

GetResponseAsync(...)

AI calls are network-bound operations and may take time.

If the original HTTP request is cancelled, there may be no reason to continue processing a response that nobody is waiting for.

Passing cancellation through the call chain is a normal .NET practice and particularly useful for external operations.


Logging AI Operations Safely

We added:

ILogger<AiChatService>

to the service.

That lets us record operational events such as:

_logger.LogInformation(
    "Sending AI chat request.");

and failures:

_logger.LogError(
    ex,
    "AI chat request failed.");

But avoid automatically doing this:

_logger.LogInformation(
    "Prompt: {Prompt}",
    message);

Prompts may contain:

  • customer information

  • financial information

  • private business data

  • personally identifiable information

  • confidential documents

Good observability requires deciding what should and should not enter your logs.


Handling AI Failures

AI providers are external dependencies.

Requests can fail because of:

  • invalid credentials

  • rate limits

  • network problems

  • provider outages

  • unavailable models

  • invalid configuration

  • request limits

Our service currently logs unexpected failures and rethrows them.

That is enough for today's architecture tutorial, but production applications should eventually map different failures into appropriate application behavior.

For example:

Invalid request      → 400
Unauthorized access  → 401/403
Rate limit           → 429
Provider unavailable → 503

Do not return raw provider exception details to public clients, because those details may reveal information that should remain internal.


Service Lifetimes

ASP.NET Core commonly provides three DI lifetimes:

Singleton
Scoped
Transient

Our application service is registered as:

builder.Services.AddScoped<IAiChatService, AiChatService>();

A scoped lifetime is a practical default for a request-oriented application service, especially when future implementations may depend on request-scoped services such as a database context or user context.

Do not choose a lifetime only because one option is considered faster.

Choose it based on the dependencies and state owned by the service.


Don't Store User Conversation History in the Service

A tempting next step might be:

private readonly List<ChatMessage> _history = new();

and then appending every request to that list.

Do not use that approach as general web-application conversation storage.

Multiple users may send requests concurrently.

Without proper conversation isolation, messages can be mixed between users or sessions.

Our Day 3 service therefore remains stateless:

Request
   ↓
Create messages
   ↓
Call model
   ↓
Return response

On Day 4, we will introduce conversation identity and chat history properly.


AI Output Is Not Trusted Business Data

Suppose a model responds:

Delete product 100 because it appears obsolete.

The application should not immediately execute:

await repository.DeleteAsync(100);

AI-generated output is not automatically a valid business instruction.

A useful design rule is:

AI proposes
     ↓
Application validates
     ↓
Authorization is checked
     ↓
Approved code executes

For sensitive or irreversible operations, human approval may also be appropriate.

This distinction will become especially important when we reach function calling and AI agents later in the series.


Security Checklist for an AI API

Before exposing:

/api/ai/chat

to real users, consider:

  • authentication

  • authorization

  • input validation

  • request-size limits

  • rate limiting

  • API key protection

  • sensitive-data handling

  • logging policy

  • abuse prevention

  • model cost

  • output validation

A public AI endpoint consumes external resources that may be billed.

Leaving it completely unrestricted can turn a small technical problem into a security and cost problem.


Suggested Project Structure

At the end of Day 3, our project can remain simple:

CSharpAiApi
│
├── Controllers
│   └── AiController.cs
│
├── Models
│   ├── ChatRequest.cs
│   └── ChatResponse.cs
│
├── Services
│   ├── IAiChatService.cs
│   └── AiChatService.cs
│
├── Program.cs
│
└── appsettings.json

Do not create unnecessary architectural layers simply because the application uses AI.

Start simple and introduce additional boundaries when real requirements justify them.


How This Can Grow into Clean Architecture

For a larger application, the same idea can evolve into:

API
 │
 └── Controllers
        ↓
Application
 │
 ├── Interfaces
 └── Use Cases
        ↓
Infrastructure
 │
 └── AI Provider Integration

The exact project and folder names are less important than the dependency direction.

Business use cases should not become unnecessarily coupled to a particular AI provider.


Common Error: IChatClient Cannot Be Resolved

You may see an error similar to:

Unable to resolve service for type
'Microsoft.Extensions.AI.IChatClient'

Verify that the AI client is registered:

builder.Services.AddChatClient(
    new OpenAIClient(apiKey)
        .GetChatClient(model)
        .AsIChatClient());

The registration must happen before:

builder.Build();

Common Error: API Key Is Missing

If:

builder.Configuration["AI:ApiKey"]

returns null, inspect your User Secrets:

dotnet user-secrets list

Verify that these names match your application:

AI:ApiKey
AI:Model

A configuration-key mismatch is enough to make the value unavailable.


Common Error: Model Is Unavailable

A valid API key does not guarantee access to every model.

Possible causes include:

  • incorrect model name

  • model unavailable to the account

  • retired or renamed model

  • provider-specific deployment configuration

  • incorrect endpoint or deployment settings

Read the provider error instead of automatically treating every failure as an authentication problem.


Common Error: Model Validation Does Not Run

Make sure the controller uses:

[ApiController]

and that your request model uses validation attributes such as:

[Required]
[StringLength(4000)]

With the normal [ApiController] behavior, invalid model state can automatically produce an HTTP 400 response.

This keeps repetitive validation code out of controller actions.


Common Error: /api/ai/chat Returns 404

Check that you registered controllers:

builder.Services.AddControllers();

and mapped them:

app.MapControllers();

Then verify the controller attributes:

[Route("api/[controller]")]

and:

[HttpPost("chat")]

For AiController, these produce:

POST /api/ai/chat

Common Error: Local HTTPS Certificate Problems

A local API client or curl may report a development certificate problem.

This is an ASP.NET Core development-environment issue rather than an AI-specific issue.

Make sure your development certificate is correctly configured and trusted.

Do not solve local certificate problems by disabling certificate validation in production.


What We Built Today

Today we moved from a simple AI client into a reusable ASP.NET Core application structure.

We implemented:

  • an ASP.NET Core Web API

  • centralized AI provider configuration

  • IChatClient registration

  • dependency injection

  • an application-level AI service

  • request and response contracts

  • DataAnnotations validation

  • cancellation propagation

  • logging

  • basic failure handling

  • Swagger/OpenAPI-friendly API testing

  • security boundaries for future development

The important achievement is not simply that our endpoint can return an AI-generated answer.

It is that we now have a structure that can grow.


Day 3 Checklist

Before moving to Day 4, make sure you understand:

  • how to register IChatClient with dependency injection

  • why provider configuration belongs near application startup

  • the difference between IChatClient and IAiChatService

  • how constructor injection works

  • how DataAnnotations validate API input

  • why CancellationToken should flow through the request

  • why prompts should not automatically be logged

  • why AI output is not trusted business data

  • why conversation history should not be stored in an unisolated shared list

  • how to test the endpoint using an API UI or cURL

If these concepts are clear, our application is ready to become conversational.


What's Next: Day 4

Right now, every request is independent.

Suppose a user sends:

My name is Alex.

and then asks:

What is my name?

Our current application does not automatically include the first message in the second request.

The model therefore has no application-managed conversation history.

In Day 4: Adding Conversation Memory to a C# AI Application, we will solve that problem.

We will explore:

  • conversation IDs

  • chat history

  • user messages

  • assistant messages

  • sending previous messages back to the model

  • keeping conversations isolated between users

  • in-memory conversation storage

  • limitations of in-memory state

  • preparing conversation storage for a database

The request flow will evolve into:

HTTP Request
      ↓
Conversation ID
      ↓
Conversation History
      ↓
AI Service
      ↓
IChatClient
      ↓
AI Model
      ↓
Assistant Response
      ↓
Updated Conversation History

This will be our first step toward building an AI assistant that remembers context across multiple requests.


Final Thoughts

Connecting an AI model to ASP.NET Core is relatively easy.

Building the integration so that it remains maintainable as the application grows requires more thought.

Today we kept provider configuration near the application's startup layer, registered IChatClient with dependency injection, placed AI behavior inside a reusable service, added API contracts and validation, and kept the HTTP layer focused on HTTP concerns.

We also avoided unnecessary complexity.

There is no need to introduce dozens of patterns simply because an application uses AI.

The goal is to create useful boundaries:

HTTP
 ↓
Application Service
 ↓
AI Abstraction
 ↓
Provider

Day 1 proved that our C# application could communicate with an AI model.

Day 2 explained why IChatClient matters.

Day 3 turned that abstraction into a reusable ASP.NET Core API architecture.

Next, we will give that application something every useful chat assistant eventually needs: conversation memory.

Comments 0