Connect AI to a Database in C# with ASP.NET Core and EF Core | Day 8
Our AI assistant can now call approved C# functions.
In Day 7, we created a get_product_stock tool that allowed the model to request current inventory information.
But our inventory data came from an in-memory list:
private static readonly List<ProductStockInfo>
Products = [...];That was useful for learning function calling. Real applications, however, usually store inventory, orders, customers, invoices, and other business data in a database.
Today we will replace that temporary data source with Entity Framework Core.
Our new flow will be:
User
↓
AI Model
↓
get_product_stock
↓
C# Tool
↓
Inventory Service
↓
Entity Framework Core
↓
Database
↓
Verified Data
↓
AI Model
↓
Final AnswerThe most important part is the boundary.
The AI model will not receive a database connection. It will not generate arbitrary SQL that our application blindly executes. It will not receive unrestricted access to database tables.
Instead, the model requests a narrow application capability, while normal C# code controls how data is retrieved.
In this tutorial, we will cover:
adding Entity Framework Core
creating a
Productentityconfiguring
AppDbContextcreating and applying a migration
replacing in-memory inventory data
querying asynchronously with EF Core
using
AsNoTracking()for read-only queriesprotecting tenant boundaries
limiting data exposed to AI
avoiding AI-generated SQL
keeping deterministic business rules in C#
designing database-backed AI tools for production
By the end, our C# AI assistant will answer inventory questions using data retrieved from a real database.
Previously in This Series
We are building one practical AI application with C# and ASP.NET Core.
Day 1: Build Your First AI Application with C# and .NET
Connected C# to an AI model.
Day 2: Understanding IChatClient
Introduced a provider-independent AI abstraction.
Day 3: Build a Reusable AI Chat Service
Moved AI integration into an ASP.NET Core architecture.
Day 4: Add Conversation Memory
Added multi-turn conversation history.
Day 5: Stream AI Responses
Streamed generated responses to the client.
Day 6: Structured AI Output
Converted AI responses into strongly typed C# results.
Day 7: Function Calling
Allowed the model to request approved C# application capabilities.
Today we keep the Day 7 tool architecture but replace:
In-Memory Listwith:
Entity Framework Core
↓
DatabaseSo Day 8 focuses on real data access rather than repeating function-calling concepts.
What Are We Building?
Imagine our database contains:
| Product | Current Stock | Reorder Level |
|---|---|---|
| Wireless Mouse | 4 | 10 |
| Mechanical Keyboard | 18 | 8 |
| USB-C Hub | 0 | 5 |
The user asks:
How many Wireless Mouse units do we currently have?The model should not know the answer in advance.
Instead:
AI
↓
get_product_stock
↓
InventoryService
↓
AppDbContext
↓
Products TableEF Core retrieves:
ProductName = Wireless Mouse
CurrentStock = 4
ReorderLevel = 10The model can then answer:
Wireless Mouse currently has 4 units in stock.
Its reorder level is 10, so stock is below
the configured threshold.The model provides the language.
The database provides the facts.
Why Keep the Database Behind Application Services?
A direct architecture such as:
AI
↓
Databasegives the model too much responsibility.
A better design is:
AI Model
↓
Approved Tool
↓
Application Service
↓
EF Core
↓
DatabaseEach layer has a clear responsibility.
AI Model understands the natural-language request.
Tool exposes a narrow capability.
Application Service controls the operation and applies application rules.
EF Core performs controlled data access.
Database remains the authoritative source of business data.
This separation also means the database implementation can change without redesigning the AI layer.
Step 1: Install Entity Framework Core
If your application does not already use EF Core, install the required packages.
For SQL Server:
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.DesignFor PostgreSQL:
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQLThe application architecture remains the same. Only provider-specific configuration changes.
The main examples below use SQL Server.
Step 2: Create the Product Entity
Create:
Entities/Product.csAdd:
using System.ComponentModel.DataAnnotations;
public sealed class Product
{
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; }
= string.Empty;
[MaxLength(100)]
public string? Sku { get; set; }
public int CurrentStock { get; set; }
public int ReorderLevel { get; set; }
public bool IsActive { get; set; } = true;
}This is a persistence entity.
It represents application data rather than an AI-specific model. That separation is useful because your database design should not depend on what one AI prompt happens to need.
Step 3: Create AppDbContext
Create:
Data/AppDbContext.csAdd:
using Microsoft.EntityFrameworkCore;
public sealed class AppDbContext
: DbContext
{
public AppDbContext(
DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<Product> Products =>
Set<Product>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(
entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Name)
.HasMaxLength(200)
.IsRequired();
entity.Property(x => x.Sku)
.HasMaxLength(100);
entity.HasIndex(x => x.Name);
entity.HasIndex(x => x.Sku);
});
}
}Our AI layer does not need to know how this context is configured.
It only communicates through the application service.
Step 4: Configure the Database
Add a connection string to appsettings.json.
For example:
{
"ConnectionStrings": {
"DefaultConnection": "Server=YOUR_SERVER;Database=CSharpAiDb;Trusted_Connection=True;TrustServerCertificate=True"
}
}Do not commit production database credentials to source control.
Production secrets should come from an appropriate secure configuration mechanism for your hosting environment.
Now register EF Core in Program.cs:
using Microsoft.EntityFrameworkCore;
string connectionString =
builder.Configuration
.GetConnectionString(
"DefaultConnection")
?? throw new InvalidOperationException(
"Connection string 'DefaultConnection' was not found.");
builder.Services.AddDbContext<AppDbContext>(
options =>
options.UseSqlServer(
connectionString));For PostgreSQL:
builder.Services.AddDbContext<AppDbContext>(
options =>
options.UseNpgsql(
connectionString));In a typical ASP.NET Core request, a scoped AppDbContext works naturally with our current flow:
HTTP Request
↓
Inventory Assistant
↓
Inventory Tool
↓
Inventory Service
↓
AppDbContextDo not store a DbContext in a static field or treat it as a permanent application-wide connection.
Step 5: Create the Database Migration
If the EF Core command-line tool is not installed:
dotnet tool install --global dotnet-efCreate the migration:
dotnet ef migrations add CreateProductsApply it:
dotnet ef database updateEF Core will create the database schema represented by the model and migration.
Step 6: Add Sample Data
For development, add a few products so we can test the assistant.
For example:
if (app.Environment.IsDevelopment())
{
using IServiceScope scope =
app.Services.CreateScope();
AppDbContext dbContext =
scope.ServiceProvider
.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync();
if (!await dbContext.Products.AnyAsync())
{
dbContext.Products.AddRange(
new Product
{
Name = "Wireless Mouse",
Sku = "WM-001",
CurrentStock = 4,
ReorderLevel = 10
},
new Product
{
Name = "Mechanical Keyboard",
Sku = "MK-001",
CurrentStock = 18,
ReorderLevel = 8
},
new Product
{
Name = "USB-C Hub",
Sku = "UCH-001",
CurrentStock = 0,
ReorderLevel = 5
});
await dbContext.SaveChangesAsync();
}
}This is development sample data.
Production migrations and seeding should follow your application's deployment strategy rather than automatically copying this startup approach.
Step 7: Replace the In-Memory Inventory Service
Our existing interface can remain unchanged:
public interface IInventoryService
{
Task<ProductStockInfo?>
GetProductStockAsync(
string productName,
CancellationToken cancellationToken = default);
}Now replace the Day 7 in-memory implementation:
using Microsoft.EntityFrameworkCore;
public sealed class InventoryService
: IInventoryService
{
private readonly AppDbContext _dbContext;
public InventoryService(
AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<ProductStockInfo?>
GetProductStockAsync(
string productName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(productName))
{
return null;
}
string normalizedName =
productName.Trim();
return await _dbContext.Products
.AsNoTracking()
.Where(x =>
x.IsActive &&
x.Name == normalizedName)
.Select(x =>
new ProductStockInfo
{
ProductName = x.Name,
CurrentStock = x.CurrentStock,
ReorderLevel = x.ReorderLevel
})
.FirstOrDefaultAsync(
cancellationToken);
}
}This is the main change in Day 8.
Before:
InventoryService
↓
List<ProductStockInfo>Now:
InventoryService
↓
AppDbContext
↓
DatabaseThe AI-facing architecture did not need to change.
Why Use AsNoTracking()?
Our stock lookup only reads data.
It does not update the returned product.
Therefore:
.AsNoTracking()is appropriate for this query.
We also project directly into:
ProductStockInfoinstead of loading the entire database entity.
Our tool only receives:
ProductName
CurrentStock
ReorderLevelIt does not receive unrelated fields simply because they exist in the database.
A useful rule is:
Retrieve and expose only the data required for the tool's job.
This keeps queries focused and reduces unnecessary data exposure.
Step 8: Keep the Existing AI Tool
The Day 7 tool can continue using IInventoryService:
using System.ComponentModel;
public sealed class InventoryTools
{
private readonly IInventoryService
_inventoryService;
public InventoryTools(
IInventoryService inventoryService)
{
_inventoryService =
inventoryService;
}
[Description(
"Gets the current stock and reorder level for a product.")]
public async Task<ProductStockInfo?>
GetProductStockAsync(
[Description(
"The exact product name to look up.")]
string productName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(productName))
{
throw new ArgumentException(
"Product name is required.",
nameof(productName));
}
if (productName.Length > 200)
{
throw new ArgumentException(
"Product name is too long.",
nameof(productName));
}
return await _inventoryService
.GetProductStockAsync(
productName.Trim(),
cancellationToken);
}
}The tool does not care whether inventory comes from:
Memory
SQL Server
PostgreSQL
API
ERPIt depends on:
IInventoryServiceThat is the boundary we want.
Step 9: Keep the Function Registration
The function created in Day 7 can also remain:
AIFunction getProductStock =
AIFunctionFactory.Create(
_inventoryTools.GetProductStockAsync,
name: "get_product_stock",
description:
"Gets current stock and reorder level for a product.");And:
var options = new ChatOptions
{
Tools =
[
getProductStock
]
};The model sees:
get_product_stockIt does not need to see:
Products table
DbContext
SQL query
Connection string
Database passwordDatabase implementation details stay inside the application.
Step 10: Test the Database-Backed Assistant
Send:
POST /api/inventory-assistant/ask
Content-Type: application/jsonRequest:
{
"message": "How many Wireless Mouse units do we currently have?"
}The runtime flow is now:
HTTP Request
↓
InventoryAssistantService
↓
IChatClient
↓
AI requests get_product_stock
↓
InventoryTools
↓
IInventoryService
↓
InventoryService
↓
AppDbContext
↓
Database
↓
Tool Result
↓
AI
↓
Final ResponseA possible response:
{
"answer": "Wireless Mouse currently has 4 units in stock. Its reorder level is 10, so it is below the configured reorder threshold."
}The wording is generated by the model.
The stock values come from the database.
During development, verify the underlying database record as well. This helps distinguish database problems from tool or AI-response problems.
Handle Products That Do Not Exist
Suppose the user asks:
What is the stock of Gaming Coffee Machine?Our current query returns null if no matching product exists.
For production applications, an explicit lookup result can make this clearer:
public sealed class ProductStockLookupResult
{
public bool Found { get; init; }
public string? ProductName { get; init; }
public int? CurrentStock { get; init; }
public int? ReorderLevel { get; init; }
}A missing product could then return:
{
"found": false,
"productName": null,
"currentStock": null,
"reorderLevel": null
}This is easier for both application code and the model to interpret than an ambiguous result.
Use Stable Identifiers as the Application Grows
Our tutorial uses an exact product name because it is easy to understand.
Real users may type:
mouse
wireless mice
WirelessMouse
the wireless mouseRather than giving the model flexible database access, a larger application can expose a separate capability:
search_productsThat tool might return:
[
{
"id": 17,
"name": "Wireless Mouse",
"sku": "WM-001"
}
]Then the workflow becomes:
User's Product Name
↓
search_products
↓
ProductId = 17
↓
get_product_stock
↓
Verified StockStable identifiers such as ProductId, SKU, or WarehouseId reduce ambiguity in business operations.
Do Not Let the AI Generate Arbitrary SQL
Avoid exposing a generic capability such as:
ExecuteSqlAsync(
string sql)The safer architecture is:
Natural-Language Request
↓
AI Selects Approved Tool
↓
Application Chooses Query
↓
EF Core Executes Controlled LINQThe model can decide:
I need current product stock.The application decides exactly how that information is retrieved.
EF Core query parameterization is useful, but SQL injection is not the only security concern.
Even a technically safe query must still answer:
Is the user authenticated?
Is the user authorized?
Does this record belong to the user's tenant?
Should this field be exposed?Application authorization remains essential.
Protect Tenant Boundaries
Multi-tenant applications need an additional boundary.
Suppose Product includes:
public int TenantId { get; set; }A tenant-aware query might include:
return await _dbContext.Products
.AsNoTracking()
.Where(x =>
x.TenantId == tenantId &&
x.IsActive &&
x.Name == normalizedName)
.Select(x =>
new ProductStockInfo
{
ProductName = x.Name,
CurrentStock = x.CurrentStock,
ReorderLevel = x.ReorderLevel
})
.FirstOrDefaultAsync(
cancellationToken);But tenantId should come from trusted application context—not from a model-generated argument.
The secure flow is:
Authenticated Request
↓
Trusted Tenant Context
↓
InventoryService
↓
Tenant-Filtered QueryThe AI can choose which approved capability it needs.
It should not decide which tenant it belongs to.
Keep Deterministic Business Rules in C#
Suppose the business rule is:
CurrentStock <= 0
→ OutOfStock
CurrentStock <= ReorderLevel
→ LowStock
Otherwise
→ HealthyWe do not need AI to calculate this.
C# can do it reliably:
public static string GetStockStatus(
int currentStock,
int reorderLevel)
{
if (currentStock <= 0)
{
return "OutOfStock";
}
if (currentStock <= reorderLevel)
{
return "LowStock";
}
return "Healthy";
}The application can provide:
{
"productName": "Wireless Mouse",
"currentStock": 4,
"reorderLevel": 10,
"stockStatus": "LowStock"
}Then the model explains verified business state instead of inventing the rule.
A useful architecture is:
Database
↓
Deterministic C# Logic
↓
Verified Facts
↓
AI ExplanationUse AI where language understanding or flexible reasoning adds value.
Keep deterministic application rules in C#.
Propagate Cancellation
Our database method accepts:
CancellationToken cancellationTokenand passes it to:
FirstOrDefaultAsync(
cancellationToken)That allows cancellation to flow through the application:
HTTP Request
↓
AI Service
↓
Tool
↓
Inventory Service
↓
EF CoreIf the request is cancelled, unnecessary work can stop sooner.
When You May Need IDbContextFactory
Our current HTTP request works naturally with a scoped AppDbContext.
Some applications have different lifetime requirements, including:
Blazor Server
background processing
long-running workflows
multiple independent units of work
For these scenarios, IDbContextFactory<AppDbContext> may be useful.
For example:
builder.Services
.AddDbContextFactory<AppDbContext>(
options =>
options.UseSqlServer(
connectionString));Then a service can create a context for a specific operation:
await using AppDbContext dbContext =
await _dbContextFactory
.CreateDbContextAsync(
cancellationToken);Do not switch to a factory automatically.
Choose the context strategy that matches the application's lifetime and unit-of-work requirements.
Also avoid concurrent operations on the same DbContext instance.
Design Tools with Database Performance in Mind
Adding AI does not remove normal database engineering concerns.
Consider:
indexes
projections
bounded result sizes
query frequency
database round trips
pagination
cancellation
caching where appropriate
Tool design also affects query performance.
Suppose the user asks:
Show stock for 50 products.Calling:
get_product_stock50 times may create unnecessary database round trips.
A bounded batch capability can be better:
Task<IReadOnlyList<ProductStockInfo>>
GetProductStocksAsync(
IReadOnlyCollection<int> productIds,
CancellationToken cancellationToken);Similarly, a search tool should not return an unlimited product table.
For example:
return await _dbContext.Products
.AsNoTracking()
.Where(x =>
x.IsActive &&
x.Name.Contains(searchTerm))
.OrderBy(x => x.Name)
.Take(20)
.Select(x =>
new ProductSearchResult
{
Id = x.Id,
Name = x.Name,
Sku = x.Sku
})
.ToListAsync(
cancellationToken);The application decides:
Take(20)not the model.
Do Not Expose Unnecessary Data
A database entity may contain much more information than an AI tool needs.
For example:
Name
Stock
Supplier Cost
Margin
Internal Notes
Audit Fields
Security MetadataIf the tool only needs:
Name
CurrentStock
ReorderLevelproject only those fields.
Do not rely on a prompt saying:
Don't mention private fields.A stronger approach is not to expose those fields to the model at all.
Handle Database Errors Safely
If the database becomes unavailable, avoid exposing raw infrastructure errors such as:
Server=...
Database=...
Login failed...to the model or end user.
Return controlled application behavior such as:
Inventory information is temporarily unavailable.
Please try again later.Technical details can be logged internally according to your logging and privacy policy.
Useful operational information may include:
Tool Name
Duration
Success / Failure
Conversation ID
Tenant IDAvoid unnecessarily logging sensitive prompts, full tool results, credentials, or connection strings.
Repository Pattern Is Optional
Some applications use:
Tool
↓
Application Service
↓
Repository
↓
EF CoreOthers use:
Tool
↓
Application Service
↓
EF CoreBoth can be reasonable.
A repository should provide a meaningful persistence boundary, not exist simply because AI has been added.
The AI integration should fit the architecture of your application rather than forcing every project into the same pattern.
Updated Project Structure
After Day 8, our project may look like:
CSharpAiApi
│
├── AI
│ └── InventoryTools.cs
│
├── Controllers
│ ├── AiController.cs
│ ├── InventoryAiController.cs
│ └── InventoryAssistantController.cs
│
├── Data
│ └── AppDbContext.cs
│
├── Entities
│ └── Product.cs
│
├── Migrations
│ └── ...
│
├── Models
│ ├── AiChatResult.cs
│ ├── AiConversation.cs
│ ├── AiStreamContext.cs
│ ├── ChatRequest.cs
│ ├── InventoryAnalysisResult.cs
│ ├── InventoryAssistantRequest.cs
│ └── ProductStockInfo.cs
│
├── Services
│ ├── IAiChatService.cs
│ ├── AiChatService.cs
│ ├── IConversationStore.cs
│ ├── InMemoryConversationStore.cs
│ ├── IInventoryAssistantService.cs
│ ├── InventoryAssistantService.cs
│ ├── IInventoryService.cs
│ └── InventoryService.cs
│
├── Program.cs
└── appsettings.jsonThe important path is:
Natural Language
↓
AI
↓
Approved Function
↓
Application Service
↓
Controlled EF Core Query
↓
DatabaseCommon Problems When Connecting AI to EF Core
Unable to Resolve AppDbContext
Check that it is registered:
builder.Services.AddDbContext<AppDbContext>(
options =>
options.UseSqlServer(
connectionString));Also verify the consuming service registration.
No Database Provider Has Been Configured
Make sure the appropriate provider package is installed and configuration contains:
options.UseSqlServer(...)or:
options.UseNpgsql(...)The Table Does Not Exist
Check that migrations were created and applied:
dotnet ef migrations add CreateProducts
dotnet ef database updateThe AI Returns Incorrect Stock
Trace the data through each layer:
Database Record
↓
EF Core Query
↓
Projection
↓
Tool Result
↓
AI ResponseThis helps identify whether the problem is data access, tool behavior, or model interpretation.
Concurrent DbContext Errors
Do not run overlapping operations on the same context instance.
Await EF Core operations properly and use a context strategy appropriate for the application's unit of work.
Sensitive Fields Reach the Model
Fix the projection.
Do not depend only on prompt instructions to hide data that should never have been provided to the model.
Production Checklist
Before allowing AI tools to query production data, verify:
authentication is enforced
authorization happens in application code
tenant filtering is enforced
tenant identity comes from trusted context
database credentials are never exposed to the model
arbitrary SQL execution is unavailable
tools expose narrow business capabilities
queries return only required fields
read-only queries use an appropriate tracking strategy
result sizes are bounded
cancellation is propagated
DbContextis not used concurrentlysensitive fields are excluded
infrastructure errors are sanitized
logging follows privacy requirements
important query patterns are efficient
deterministic business rules remain in C#
write operations have stronger controls
What We Built Today
At the end of Day 7:
User
↓
AI
↓
C# Tool
↓
In-Memory DataAfter Day 8:
User
↓
AI
↓
Approved C# Tool
↓
Application Service
↓
Entity Framework Core
↓
Database
↓
Verified Business Data
↓
AI
↓
Final AnswerThe important change is not simply that we added a database.
We preserved the application boundary.
The model sees a capability such as:
get_product_stockIt does not need to know the table name, SQL query, connection string, database password, or database server.
That separation improves security, testing, maintainability, and flexibility.
Day 8 Checklist
Before moving to Day 9, make sure you understand:
how EF Core fits behind an AI tool
how to create and register
AppDbContexthow to create and apply migrations
why application services should control database access
why
AsNoTracking()is useful for read-only querieswhy tool results should use focused projections
why AI should not execute arbitrary SQL
why authorization is separate from SQL injection protection
why tenant identity must come from trusted context
why deterministic business rules belong in C#
why one
DbContextshould not be used concurrentlywhen
IDbContextFactory<TContext>may be usefulwhy AI tool design affects database performance
why result sizes should be bounded
why unnecessary database fields should stay outside the AI context
What's Next: Day 9
Our AI assistant can now retrieve structured business data from a database.
But important business knowledge also lives in:
PDF files
Documentation
Policies
Manuals
Knowledge-base articles
Internal documentsUsers may ask:
What is our return policy?
What does the employee handbook say about annual leave?
Which part of the manual explains calibration?Sending every document to the model for every question would be inefficient.
In Day 9: Build a RAG System in C# with .NET, we will introduce Retrieval-Augmented Generation.
The architecture will evolve into:
User Question
↓
Retrieve Relevant Knowledge
↓
Selected Context
↓
AI Model
↓
Grounded AnswerWe will focus on how RAG complements the structured database tools we built today without repeating the database or function-calling concepts.
Final Thoughts
Connecting AI to a database should not mean:
Give AI unrestricted SQL access.A stronger approach is:
Give AI carefully designed
application capabilities.The model understands the user's natural-language request.
The tool exposes an approved capability.
The C# application controls how that capability works.
EF Core performs the database query.
The database remains the authoritative source of business facts.
The model then communicates those verified facts naturally.
Our final boundary is:
AI Understanding
↓
Controlled Tool
↓
Application Logic
↓
EF Core
↓
Database FactsThe model should not become your database layer.
It should work through your application layer.
Day 8 connected our AI assistant to real business data.
Day 9 will connect it to a different type of information: unstructured knowledge through Retrieval-Augmented Generation (RAG).
Comments 0