Build a Reusable AI Chat Service in ASP.NET Core with Dependency Injection | Day 3
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 ModelThis 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/chatA 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 Modelwe 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 ChecksAI 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 CSharpAiApiMove into the project:
cd CSharpAiApiYou 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 OpenAIPackage 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 SDKMost 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 initStore 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 ProviderThis 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:
ServicesThen create:
IAiChatService.csAdd:
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 typesThe application only knows that it can ask the service a question.
Step 6: Implement AiChatService
Create:
Services/AiChatService.csAdd:
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:
IChatClientrather 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:
IInventoryAiServicewith methods such as:
Task<string> ExplainStockAsync(...);or:
ISalesAiServicewith:
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:
IAiChatServiceASP.NET Core creates AiChatService.
When AiChatService asks for:
IChatClientASP.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.csInstead 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.csAdd:
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.csAdd:
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 responseIt 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 runASP.NET Core will display the configured development URLs.
For example:
https://localhost:7001The actual port can be different on your machine.
Our endpoint is:
POST /api/ai/chatSend:
{
"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/chatUse 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/chatASP.NET Core binds the JSON body to:
ChatRequestModel 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:
ChatResponseThis 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:
IAiChatServiceis 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 cancellationTokenand 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 → 503Do 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
TransientOur 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 responseOn 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 executesFor 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/chatto 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.jsonDo 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 IntegrationThe 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 listVerify that these names match your application:
AI:ApiKey
AI:ModelA 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/chatCommon 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
IChatClientregistrationdependency 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
IChatClientwith dependency injectionwhy provider configuration belongs near application startup
the difference between
IChatClientandIAiChatServicehow constructor injection works
how DataAnnotations validate API input
why
CancellationTokenshould flow through the requestwhy 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 HistoryThis 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
↓
ProviderDay 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