For the past decade, .NET architects have been perfecting a craft. Clean separation of concerns. Domain-driven design. Event-driven microservices. CQRS. Hexagonal architecture. The patterns are mature, battle-tested, and well-understood. Teams have learned how to build systems that are reliable, maintainable, and scalable.
Then AI arrived - not as a feature, but as an expectation.
Not the AI of a sentiment analysis endpoint bolted onto the side of an API. Not a classification model embedded in a background job. The new expectation is AI that reasons across your entire domain, remembers context across sessions, orchestrates multi-step workflows autonomously, and integrates with every surface of your product simultaneously.
This is what it means to be AI-native. And it does not fit cleanly into the architectures most .NET teams built over the last decade.
This article is a practical guide to bridging that gap. We will examine how to evolve a modern .NET architecture to support AI-native workloads - without discarding everything you have built and without compromising the engineering discipline that makes .NET systems trustworthy.
Who is this for? Senior .NET engineers, architects, and tech leads who are integrating AI deeply into production systems and need a structural framework for doing it correctly.
What AI-Native Actually Means
The term AI-native is used loosely in the industry, so let us define it precisely for the context of .NET architecture.
An AI-native system is one in which the AI capability is structurally integrated into the architecture - not added as a feature on top of it. The difference matters enormously in practice.
Consider two approaches to adding a "smart contract summarization" feature to a legal SaaS product.
The first approach creates a new API endpoint, calls an LLM from inside a service method, returns the result. The AI is a black box embedded in a single method in a single service. It has no awareness of the domain model, no memory of previous interactions, no ability to take follow-up actions, and no path for the engineering team to reason about what the AI actually did or why.
The second approach models the AI capability as a first-class architectural concern. The AI orchestrator knows the domain. It has access to the company's private knowledge base via semantic search. It can take actions in the system - drafting a document, flagging a clause, notifying a reviewer - via well-defined tool interfaces. Every AI interaction is observable, traceable, and auditable. The LLM provider is an infrastructure dependency, not a hardcoded implementation detail.
The second approach is AI-native. It is also significantly harder to build. This article gives you the framework to do it correctly.
Why Traditional .NET Architectures Struggle with AI
The Clean Architecture and Domain-Driven Design patterns that .NET teams have embraced are fundamentally synchronous, deterministic, and stateless between requests. A command handler receives input, applies business rules, persists state, and returns. The output is predictable given the input. The execution time is bounded and measurable.
AI workloads break every one of these assumptions.
LLM calls are slow. A single inference call to GPT-4o or Claude 3.7 takes anywhere from 500ms to 30 seconds depending on the prompt length and output complexity. An application command handler that takes 15 seconds to complete will time out, exhaust thread pool resources, and generate a torrent of support tickets.
LLM outputs are non-deterministic. Two identical prompts with the same model can produce subtly different outputs. Unit tests that assert on exact string output will fail randomly. Deterministic business logic cannot assume deterministic AI output.
AI workflows are long-running and stateful. An agent that researches a topic, drafts a document, asks the user for clarification, incorporates the feedback, and then publishes the result is not a request-response operation. It is a workflow that spans multiple turns, potentially across multiple sessions. The request-scoped dependency injection lifetime and the stateless HTTP handler model do not accommodate this naturally.
AI introduces external costs per call. Every LLM call costs money in API tokens. Traditional architectures have no concept of per-call economic cost - there is no mechanism to budget, throttle, or optimize LLM usage without building it explicitly.
The context window is a shared resource. An LLM call is not just "pass input, get output." The entire conversation history, retrieved documents, tool definitions, and system prompt all compete for space inside a fixed context window. Managing this window is a first-class engineering problem that has no analog in traditional CRUD architecture.
These are not minor inconveniences. They are fundamental mismatches between the assumptions embedded in traditional .NET architecture and the requirements of AI-native workloads. Addressing them requires structural changes, not just new NuGet packages.
The AI-Native Architecture Stack
Before writing any code, it helps to visualize how an AI-native .NET system is layered. The following stack extends the familiar Clean Architecture layers with the AI-specific concerns that sit between the application layer and the external AI infrastructure.
┌──────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ASP.NET Core Minimal APIs Blazor gRPC │
│ Streaming responses WebSockets SignalR │
├──────────────────────────────────────────────────────────────────┤
│ AI Orchestration Layer ← NEW │
│ Semantic Kernel Kernel Agent runtime │
│ Planner Memory manager Plugin registry │
│ Prompt template engine Token budget manager │
├──────────────────────────────────────────────────────────────────┤
│ Application Layer │
│ Command / Query handlers Domain services │
│ AI Tool implementations Workflow coordinators │
├──────────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ Entities Value objects Domain events │
│ AI-aware aggregates Semantic metadata │
├──────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ PostgreSQL Redis Blob storage │
│ Vector DB (pgvector / Qdrant / Azure AI Search) │
│ LLM providers (OpenAI / Azure OpenAI / Anthropic) │
│ Embedding providers Observability (OTEL) │
└──────────────────────────────────────────────────────────────────┘The AI Orchestration Layer is the critical addition. It is not part of the domain - it does not contain business rules. It is not part of the infrastructure - it is not a database driver or an HTTP client. It is the translation layer between your domain logic and the probabilistic, non-deterministic, token-consuming world of large language models. Keeping it as a distinct layer is what makes the rest of the architecture testable, maintainable, and provider-agnostic.
Semantic Kernel as the AI Orchestration Layer
Microsoft's Semantic Kernel is the .NET ecosystem's answer to the AI orchestration problem. It provides the abstractions that allow you to build the AI Orchestration Layer without coupling your application to a specific LLM provider, embedding model, or vector store.
The core concept in Semantic Kernel is the Kernel - a dependency injection container for AI services and plugins. Everything flows through it.
// Program.cs - register Semantic Kernel with all AI services
builder.Services.AddKernel()
.AddAzureOpenAIChatCompletion(
deploymentName: config["AzureOpenAI:DeploymentName"]!,
endpoint: config["AzureOpenAI:Endpoint"]!,
apiKey: config["AzureOpenAI:ApiKey"]!)
.AddAzureOpenAITextEmbeddingGeneration(
deploymentName: config["AzureOpenAI:EmbeddingDeployment"]!,
endpoint: config["AzureOpenAI:Endpoint"]!,
apiKey: config["AzureOpenAI:ApiKey"]!)
.Plugins
.AddFromType<CustomerPlugin>()
.AddFromType<ContractPlugin>()
.AddFromType<NotificationPlugin>();
// Register vector memory store
builder.Services.AddSingleton<IVectorStore>(sp =>
new QdrantVectorStore(new QdrantClient("localhost")));Plugins as domain capability exposure
The Plugin system is how you expose your domain to the AI. A plugin is a C# class whose public methods are decorated with [KernelFunction] and natural-language descriptions. The AI reads these descriptions and decides which functions to call and in what order based on the user's intent.
public sealed class CustomerPlugin
{
private readonly ICustomerRepository _customers;
private readonly IOrderRepository _orders;
public CustomerPlugin(
ICustomerRepository customers,
IOrderRepository orders)
{
_customers = customers;
_orders = orders;
}
[KernelFunction]
[Description("Retrieve a customer profile including contact details, " +
"subscription plan, and account status.")]
public async Task<CustomerDto> GetCustomerProfileAsync(
[Description("The unique customer identifier (UUID)")]
string customerId,
CancellationToken cancellationToken = default)
{
var customer = await _customers.GetByIdAsync(
Guid.Parse(customerId), cancellationToken);
return customer is null
? throw new CustomerNotFoundException(customerId)
: CustomerDto.FromDomain(customer);
}
[KernelFunction]
[Description("List the most recent orders for a customer, " +
"sorted newest first. Returns order status, total, and items.")]
public async Task<IReadOnlyList<OrderSummaryDto>> GetRecentOrdersAsync(
[Description("The unique customer identifier (UUID)")]
string customerId,
[Description("Maximum number of orders to return. Default is 10.")]
int limit = 10,
CancellationToken cancellationToken = default)
{
var orders = await _orders.GetRecentByCustomerAsync(
Guid.Parse(customerId), limit, cancellationToken);
return orders.Select(OrderSummaryDto.FromDomain).ToList();
}
[KernelFunction]
[Description("Update the subscription plan for a customer. " +
"Valid plans are: starter, professional, enterprise.")]
public async Task<string> UpdateSubscriptionPlanAsync(
[Description("The unique customer identifier (UUID)")]
string customerId,
[Description("The new subscription plan name")]
string planName,
CancellationToken cancellationToken = default)
{
await _customers.UpdatePlanAsync(
Guid.Parse(customerId), planName, cancellationToken);
return $"Successfully updated customer {customerId} to {planName} plan.";
}
}The descriptions you write on [KernelFunction] and each parameter are not comments - they are the interface between your code and the language model. The quality of your function descriptions directly determines the reliability of the AI's decisions about when and how to call them. This is description engineering, and it is one of the most underappreciated skills in AI-native .NET development.
Designing AI-Aware Domain Models
A traditional domain entity is designed to support business rules enforced by application code. An AI-aware domain entity also needs to support semantic understanding - the ability for an AI to reason about what the entity means, not just what fields it has.
This is a subtle but important distinction. Consider a Contract entity in a legal SaaS:
// Traditional domain entity
public sealed class Contract
{
public Guid Id { get; private set; }
public string Title { get; private set; }
public string FullText { get; private set; }
public ContractStatus Status { get; private set; }
public DateTimeOffset EffectiveDate { get; private set; }
public DateTimeOffset ExpiryDate { get; private set; }
// Business rules enforced in domain
public void Approve(UserId approver)
{
if (Status != ContractStatus.PendingReview)
throw new DomainException("Only contracts pending review can be approved.");
Status = ContractStatus.Approved;
AddDomainEvent(new ContractApprovedEvent(Id, approver));
}
}
// AI-aware domain entity adds semantic metadata
public sealed class Contract
{
public Guid Id { get; private set; }
public string Title { get; private set; }
public string FullText { get; private set; }
public ContractStatus Status { get; private set; }
public DateTimeOffset EffectiveDate { get; private set; }
public DateTimeOffset ExpiryDate { get; private set; }
// Semantic metadata - generated asynchronously after entity creation
public string? AiSummary { get; private set; }
public IReadOnlyList<string> KeyClauses { get; private set; } = [];
public IReadOnlyList<string> RiskFlags { get; private set; } = [];
public float[]? EmbeddingVector { get; private set; }
public DateTimeOffset? LastIndexedAt { get; private set; }
public void ApplySemanticAnalysis(
string summary,
IReadOnlyList<string> clauses,
IReadOnlyList<string> risks,
float[] embedding)
{
AiSummary = summary;
KeyClauses = clauses;
RiskFlags = risks;
EmbeddingVector = embedding;
LastIndexedAt = DateTimeOffset.UtcNow;
AddDomainEvent(new ContractIndexedEvent(Id));
}
// Business rules unchanged - domain integrity is not AI's responsibility
public void Approve(UserId approver)
{
if (Status != ContractStatus.PendingReview)
throw new DomainException("Only contracts pending review can be approved.");
Status = ContractStatus.Approved;
AddDomainEvent(new ContractApprovedEvent(Id, approver));
}
}The semantic metadata lives in the domain entity, but it is never set by the AI directly. The domain event pipeline triggers an asynchronous background job that calls the AI, generates the semantic analysis, and then calls ApplySemanticAnalysis through a proper command. The domain's integrity is preserved. The AI capability is additive, not structural.
Vector Storage and Semantic Memory in .NET
Vector storage is the persistence layer of AI-native systems. It stores embedding vectors alongside their source content and metadata, enabling semantic search - finding documents that are meaningfully similar to a query rather than just lexically matching keywords.
In a .NET system, the vector store is infrastructure. It belongs in the Infrastructure layer and is accessed through an interface defined in the Domain or Application layer.
// Application layer interface - no vector store dependency
public interface IContractSemanticSearch
{
Task<IReadOnlyList<ContractSearchResult>> SearchAsync(
string query,
int maxResults = 10,
float minSimilarity = 0.7f,
CancellationToken ct = default);
Task IndexContractAsync(
Guid contractId,
string content,
ContractMetadata metadata,
CancellationToken ct = default);
}
// Infrastructure implementation - Qdrant vector store
public sealed class QdrantContractSemanticSearch : IContractSemanticSearch
{
private const string CollectionName = "contracts";
private readonly QdrantClient _qdrant;
private readonly ITextEmbeddingGenerationService _embeddings;
public QdrantContractSemanticSearch(
QdrantClient qdrant,
ITextEmbeddingGenerationService embeddings)
{
_qdrant = qdrant;
_embeddings = embeddings;
}
public async Task<IReadOnlyList<ContractSearchResult>> SearchAsync(
string query,
int maxResults = 10,
float minSimilarity = 0.7f,
CancellationToken ct = default)
{
// Generate embedding for the search query
var queryEmbedding = await _embeddings.GenerateEmbeddingAsync(query, ct);
// Vector similarity search in Qdrant
var results = await _qdrant.SearchAsync(
collectionName: CollectionName,
vector: queryEmbedding.ToArray(),
limit: (ulong)maxResults,
scoreThreshold: minSimilarity,
cancellationToken: ct);
return results
.Select(r => new ContractSearchResult(
ContractId: Guid.Parse(r.Payload["contract_id"].StringValue),
Title: r.Payload["title"].StringValue,
Summary: r.Payload["summary"].StringValue,
Similarity: r.Score))
.ToList();
}
public async Task IndexContractAsync(
Guid contractId,
string content,
ContractMetadata metadata,
CancellationToken ct = default)
{
// Chunk large contracts into overlapping segments
var chunks = ChunkText(content, chunkSize: 512, overlap: 64);
var points = new List<PointStruct>();
foreach (var (chunk, index) in chunks.Select((c, i) => (c, i)))
{
var embedding = await _embeddings.GenerateEmbeddingAsync(chunk, ct);
points.Add(new PointStruct
{
Id = new PointId { Uuid = Guid.NewGuid().ToString() },
Vectors = new Vectors { Vector = new Vector { Data = { embedding } } },
Payload =
{
["contract_id"] = contractId.ToString(),
["title"] = metadata.Title,
["summary"] = metadata.AiSummary ?? "",
["chunk_index"] = index,
["effective_date"] = metadata.EffectiveDate.ToString("O"),
}
});
}
await _qdrant.UpsertAsync(CollectionName, points, cancellationToken: ct);
}
private static IReadOnlyList<string> ChunkText(
string text, int chunkSize, int overlap)
{
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var chunks = new List<string>();
for (int i = 0; i < words.Length; i += chunkSize - overlap)
{
var chunk = string.Join(' ', words.Skip(i).Take(chunkSize));
if (!string.IsNullOrWhiteSpace(chunk))
chunks.Add(chunk);
if (i + chunkSize >= words.Length)
break;
}
return chunks;
}
}
Join the conversation! Your thoughts help the community grow.