One Screen the Architect Can Defend: A Reference Architecture for the .NET Agentic Service
The whole service projected on the meeting room wall, sixty lines of host wire up, and a three month junior asking where the prompt template lives. If the answer takes longer than ninety seconds or needs a second file, the architecture already lost the review.

๐๏ธ Last updated: August 2026
It is the architecture review the Friday before a greenfield It is the architecture review the Friday before a greenfield .NET agentic service ships. The senior architect projects Program.cs onto the meeting room wall: sixty lines of generic host wire up. A staff engineer with three months on the team asks the only question that matters. Where does the prompt template live? The architect points at one line: services.Configure<PromptCatalog>(builder.Configuration.GetSection("Prompts"));. The catalog binds from appsettings.json, overlays from the environment specific file, surfaces through IOptionsMonitor<PromptCatalog> for hot reload, and is read on each invocation by the agent host. The whole answer takes ninety seconds. The architecture is defensible because every question of "where does X live" has exactly one answer the team can point to in the wire up file.
That is the bar a reference architecture clears. The five preceding articles in this series each described a component: the Microsoft Agent Framework substrate, the IChatClient middleware pipeline, structured outputs as a typed contract, hosted service lifecycle, and the streaming surface. The closer assembles them into a single service the architect can defend before the first commit.
What This Article Is Not
This is not a repository you can copy and paste. It is not a vendor scaffold from dotnet new. It is not an Azure deployment recipe; the deployment surface here is generic .NET hosting, and the cloud specific wire up is a wrapper around the same generic host. It is not the only valid layering for a .NET agentic service; it is the layering a senior architect should be able to defend in a review and adjust deliberately when the project's constraints demand a different cut.
The Thesis
A .NET agentic service is a generic host with five seams the architect owns. The layering across Domain, Application, Infrastructure, and Hosting is the same canonical layering ASP.NET Core taught a decade ago. What changes is the lifetime of the IChatClient pipeline, the placement of the AgentSessionStore, the configuration surface for prompt versions and model selection, the OpenTelemetry pipeline that wraps the agent middleware, and the choice between a unified web host or a worker plus gateway split. Get those five seams right and the service is boring to operate. Get one wrong and it surfaces in production at the worst possible moment: a lifetime mismatch that captures stale options into a singleton, an environment variable override that silently changes the model on production traffic, a span graph that explodes when streaming turns chunks into events.
What the Classical .NET Version Looked Like
The pre agentic .NET version of "service that calls an external API and exposes results to a client" was small. A web host. A typed HTTP client through IHttpClientFactory. A Minimal API endpoint. An IOptions<T> block bound from appsettings.json. A logger at a constructor. The layering was implicit because the surface was small and the failure modes were familiar.
An agentic service breaks that pattern. The model client is the substrate of every interesting code path. The conversation has state that outlives the request. The middleware wrapping the model call carries telemetry, function invocation, structured output translation, and cost guardrails. The runtime mixes interactive HTTP and long running background work. The configuration surface needs to allow prompt version rollouts and model swaps without a redeploy. The shape that emerges is recognisably the four layer pattern ASP.NET Core has always supported, but each layer carries weight the classical small API version never had.
The Four Layers
Domain holds the records that describe the service's contract with itself: the ChatMessage and ChatRole from Microsoft.Extensions.AI, the application specific structured output records from Article 3, the session and identity types, and the tool contracts. Domain is dependency free. It compiles without a chat client implementation, without a hosting reference. The test is whether the project builds on its own.
Application holds the use cases: orchestration that takes a user request, resolves the right prompt, calls the agent, runs the tools, validates the structured output, and returns a result. Application depends on Domain and on the abstractions in Microsoft.Extensions.AI, IChatClient, IEmbeddingGenerator, ChatOptions, never on a specific provider. The test is whether the use case can be unit tested with a stub IChatClient that returns a canned ChatResponse.
Infrastructure holds the implementations: the configured OpenAIClient or AzureOpenAIClient wrapped into an IChatClient, the Redis backed AgentSessionStore from Article 4, the distributed cache, the function invocation registrations, and the OpenTelemetry exporter wiring. Infrastructure depends on Application's abstractions. The test is whether a swap from OpenAI to Azure OpenAI changes only the registration line in Program.cs.
Hosting is the composition root: Program.cs itself. It reads configuration, wires the M.E.AI middleware pipeline, registers the agent and session store, configures IHostedService workers from Article 4, maps the streaming endpoints from Article 5, and adds OpenTelemetry. Hosting depends on every other layer. The test is whether a new engineer can read it top to bottom and answer every "where does X live" question without opening another file.
The four layer cut is older than the Microsoft Agent Framework and older than Microsoft.Extensions.AI. The discipline, no chat client implementation leaking into a use case, no Program.cs line bleeding into a domain record, is what keeps the service defensible when the model surface shifts again in eighteen months.
Dependency Injection Lifetimes
The five seams that decide whether the agentic service runs cleanly all sit in the DI container. The lifetimes are not interchangeable.
IChatClient is Singleton. The AddChatClient extension registers the pipeline as a singleton by default, and the framework's own XML doc says exactly that: "The client is registered as a singleton service." Per request state lives in the ChatOptions and IEnumerable<ChatMessage> passed into GetResponseAsync or GetStreamingResponseAsync. The FunctionInvokingChatClient carries instance configuration, AllowConcurrentInvocation, MaximumIterationsPerRequest, but its per invocation context is tracked in an AsyncLocal<FunctionInvocationContext?>, so the singleton lifetime is safe under concurrent requests.
ChatClientAgent is Singleton. The Microsoft.Agents.AI ChatClientAgent from Article 1 is a thin sealed class over IChatClient. Its per conversation state lives in the AgentSession callers pass into RunAsync and RunStreamingAsync, not on the agent instance.
AgentSessionStore is Singleton. The Redis backed or in memory store from Article 4 is a stateless wrapper over a connection multiplexer. The persistence layer is the source of state; the store object itself is not.
AgentSession is per conversation, resolved at runtime, not registered. Sessions live and die with the conversation, not with the request. Application code resolves them from the store using a conversation identifier; they do not have a DI lifetime because they are not DI services. The session is an entity; the store is the service.
Tools registered as AIFunction instances are Singleton when stateless and resolved from scope when they need request scoped dependencies. The wrapper pattern is to register the stateful dependency as Scoped and to construct the function inside the request handler with the scoped service captured.
IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T> are three lifetimes of the same binding. IOptions<T> resolves once at app start. IOptionsSnapshot<T> is "used to access the value of TOptions for the lifetime of a request", the framework's own XML doc, which makes it Scoped. IOptionsMonitor<T> is Singleton with a hot reload event via OnChange(Action<TOptions, string?>). Prompt catalogs and model selection are IOptionsMonitor<T> so a config change rolls out without a redeploy. Static structural settings, connection strings, maximum iteration counts, are IOptions<T>.
The lifetime mismatch the architect catches at review is a Scoped service captured inside a Singleton's constructor, the classic "captive dependency" the .NET container's scope validation mode flags in Development but not in Production. Turning on scope validation in Production is a defensible decision for an agentic service because the cost of a captured stale options instance is an entire production conversation served against the wrong prompt.
Configuration
The configuration sources the generic host reads are fixed in order, last source wins. The framework loads appsettings.json, then appsettings.{EnvironmentName}.json, then optional {ApplicationName}.settings.json and its environment specific overlay if the application name is set, then User Secrets when EnvironmentName == "Development", then environment variables, then command line arguments. The ordering is implemented in HostingHostBuilderExtensions.ApplyDefaultAppConfiguration and is the canonical pattern for Host.CreateApplicationBuilder and WebApplication.CreateBuilder. The architect's lever is which layer carries which kind of value.
Structural defaults live in appsettings.json and ship in the container image. Per environment overrides live in appsettings.Production.json and friends. Secrets do not live in any of those; they live in the cloud secret store and are pulled in by a configuration provider. builder.Configuration.AddAzureKeyVault(new Uri("https://kv.vault.azure.net/"), new DefaultAzureCredential()) from the Azure.Extensions.AspNetCore.Configuration.Secrets package overlays Key Vault secrets onto the configuration root. The same pattern applies to AWS Secrets Manager and HashiCorp Vault via their providers. The discipline is that the API key is never in source, never in the image, and never typed into a CI variable that is not itself protected.
The prompt catalog and the model selection are the two surfaces that change most often without code. IOptionsMonitor<PromptCatalog> bound to a Prompts section gives the operations team a path to roll a prompt version without a deploy: flip the version pointer, the file watcher fires, OnChange callbacks rebuild dependent caches. The same pattern handles model swaps via a ModelSelection record with Provider, ModelId, and Endpoint. The middleware is wired once at startup; the values it reads are resolved fresh on each invocation.
The trap with hot reload is that not every dependent state recomputes on OnChange. A cache key generator that captures the prompt version at registration time will never see a new version come through, because the key was closed over at startup. The fix is to make the generator a function of IOptionsMonitor<T>.CurrentValue, evaluated per request.
Observability
The OpenTelemetry pipeline for the agentic service has three layers. The M.E.AI middleware emits the GenAI semantic conventions tags through its OpenTelemetryChatClient, model id, prompt token count, completion token count, finish reason, on each call. The application code adds its own activity spans through ActivitySource for the use case layer above the chat client. The OpenTelemetry .NET SDK collects both, batches, and exports through the OTLP exporter to the collector.
The wire up is small. services.AddOpenTelemetry() from the OpenTelemetry.Extensions.Hosting package returns the OTel builder. .ConfigureResource(r => r.AddService("agentic-service")) sets the service identity. .WithTracing(t => t.AddSource("YourApp.UseCases").AddSource("Experimental.Microsoft.Extensions.AI").AddOtlpExporter()) subscribes the tracer to both the application's ActivitySource and the M.E.AI source, and ships the spans to the collector. .WithMetrics(m => m.AddMeter("Experimental.Microsoft.Extensions.AI").AddOtlpExporter()) does the same for metrics; the meter name is identical to the trace source name because the M.E.AI middleware constructs both from OpenTelemetryConsts.DefaultSourceName.
The UseOpenTelemetry() extension on ChatClientBuilder is the M.E.AI hook that turns the middleware on. It is one line in the builder pipeline. The OTel SDK in the host process picks up the activity through AddSource because the middleware writes to a known source name. The two halves of the wire up, middleware on the builder, source on the tracer provider, are deliberately separate so the architect can compose them with whatever resource attributes, samplers, and exporters the deployment demands.
The trap on the OTel pipeline is span explosion under streaming. Article 5 covered why the streaming chat span lifetime spans the entire enumeration. The corollary is that per chunk span events on a high traffic streaming endpoint can blow up the collector's queue. The defence is the sampler, ParentBased(TraceIdRatioBased(0.05)) for production traffic, full sampling in staging, and a per event budget cap on chunk level events.
Deployment
The deployment surface is one of two shapes. The first is the unified web host: a single ASP.NET Core process serving the HTTP API, running the SignalR hub from Article 5, and hosting the background workers from Article 4 in the same process. The second is the worker plus gateway split: a Minimal API gateway running the streaming endpoints and SignalR hub, plus worker processes running the agent loop as BackgroundService. The two shapes share the same composition root pattern.
The decision between them is operational, not architectural. The unified host is simpler to deploy: one container, one set of health probes, one log stream. The split is the right shape when the worker fleet scales independently of the gateway, or when a long running agent loop should not share a pod with traffic serving threads. Either shape uses Host.CreateApplicationBuilder(args) for the worker, WebApplication.CreateBuilder(args) for the web host, and the same Microsoft.Extensions.AI, Microsoft.Agents.AI, and OpenTelemetry packages in both projects.
The composition root for the unified web host fits on one screen.
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddAzureKeyVault(
new Uri(builder.Configuration["KeyVault:Uri"]!),
new DefaultAzureCredential());
builder.Services.Configure<PromptCatalog>(
builder.Configuration.GetSection("Prompts"));
builder.Services.Configure<ModelSelection>(
builder.Configuration.GetSection("Model"));
builder.Services.AddChatClient(sp =>
{
var model = sp.GetRequiredService<IOptionsMonitor<ModelSelection>>().CurrentValue;
return new AzureOpenAIClient(new Uri(model.Endpoint), new DefaultAzureCredential())
.GetChatClient(model.ModelId)
.AsIChatClient();
})
.UseOpenTelemetry()
.UseLogging()
.UseDistributedCache()
.UseFunctionInvocation();
builder.Services.AddSingleton<AgentSessionStore, RedisAgentSessionStore>();
builder.Services.AddSingleton<ChatClientAgent>(sp =>
new ChatClientAgent(sp.GetRequiredService<IChatClient>(),
new ChatClientAgentOptions { Name = "PlatformCopilot" }));
builder.Services.AddHostedService<OrderInvestigationWorker>();
builder.Services.AddSignalR();
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("agentic-service"))
.WithTracing(t => t
.AddSource("PlatformCopilot.UseCases")
.AddSource("Experimental.Microsoft.Extensions.AI")
.AddOtlpExporter())
.WithMetrics(m => m
.AddMeter("Experimental.Microsoft.Extensions.AI")
.AddOtlpExporter());
builder.Services.AddHealthChecks()
.AddCheck<AgentLivenessCheck>("agent", tags: new[] { "live" });
var app = builder.Build();
app.MapAgentEndpoints();
app.MapHub<ChatHub>("/hub/chat");
app.MapHealthChecks("/health/live",
new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") });
app.Run();
Every line of that file answers one of the architect's reviewable questions. Where do secrets come from? Key Vault. Where is the prompt catalog? Prompts section, monitored. What is the model? Model section, hot reloadable. What lifetime is the chat client? Singleton, default. Where does session state live? RedisAgentSessionStore, singleton over a Redis multiplexer. What runs in the background? OrderInvestigationWorker from Article 4. What does streaming use? ChatHub from Article 5. The composition root reads like a series of architectural commitments, each one a single line.
The configuration shape that file binds against is equally small.
{
"KeyVault": { "Uri": "https://kv-platform-prod.vault.azure.net/" },
"Model": {
"Provider": "AzureOpenAI",
"Endpoint": "https://oai-platform.openai.azure.com/",
"ModelId": "gpt-4o-2024-11-20"
},
"Prompts": {
"Version": "2026-08-15",
"Catalog": {
"Triage": "You are an internal platform copilot.",
"Summary": "Summarise the resolution in three sentences."
}
},
"Otlp": { "Endpoint": "http://otel-collector.observability:4317" }
}
The PromptCatalog and ModelSelection records on the application side are plain C# records with init only setters, bound by name from the matching configuration sections. Adding a prompt is one entry under Prompts:Catalog. Rolling a prompt version is one change to Prompts:Version. Swapping models is one change to Model:ModelId. The deploy artefact does not change.
Failure Modes, Worst First
The well architected version of this surface is invisible. The failure modes are the ones that surface as production incidents.
The first is the captive dependency. A Scoped service captured into a Singleton's constructor, usually IOptionsSnapshot<T> held by a Singleton agent, silently freezes at construction time. Production traffic serves against the wrong prompt for the lifetime of the process. The defence is validateScopes: true on the service provider in Production, not only Development.
The second is the hot reload that does not propagate. IOptionsMonitor<T>.OnChange fires on the source's reload event. A computed value derived from CurrentValue and cached without an OnChange subscription will serve the stale version forever. The defence is to call CurrentValue at the point of use, never at registration time.
The third is the secret in the prompt history. A user prompt that includes an API key or a customer record ends up in the model provider's request log, in the distributed cache, and in the OpenTelemetry span as a tag value. Once the secret is in a third party log the rotation cost is real. The defence is a pre flight redaction pass on the message list before it reaches GetResponseAsync, plus an OTel tag filter to ensure the span does not carry the raw content.
The fourth is the configuration drift between staging and production. The two appsettings.{Environment}.json files diverge over time. A change made to one without the other surfaces as a "works in staging" incident. The defence is a single source of truth for environment specific values, usually a shared store like Azure App Configuration, with the JSON files reserved for genuinely environment local concerns.
The fifth is the OTel span explosion under streaming. Every chunk on a high traffic endpoint emits a chunk level metric and may emit an event on the active span. The collector queue grows, the exporter drops, the application starts blocking when its buffer fills. The defence is a sampler with a low ratio on production traffic and an explicit cap on per event budget on the streaming span.
The sixth is the silent provider swap. A ModelSelection.ModelId change rolled through hot reload swaps the model on next request without an audit trail. The user facing behaviour shifts. The cost surface shifts. The defence is to emit an ActivitySource event whenever the resolved ModelId differs from the previous value, and to gate the configuration source's write surface so only one place can flip the model.
The seventh is the scope validation bypass. The default for WebApplication.CreateBuilder enables scope validation in Development only. Production runs without it. A Scoped service mistakenly registered as a Singleton dependency does not throw; it just captures and freezes. The defence is the explicit builder.Host.UseDefaultServiceProvider(opts => { opts.ValidateScopes = true; opts.ValidateOnBuild = true; }) line in the composition root, applied to every environment.
The Architect's Checklist
๐๏ธ The four layer cut, Domain, Application, Infrastructure, Hosting, is enforced by project references; no Application project references Microsoft.AspNetCore.*, no Domain project references Microsoft.Extensions.AI.
๐๏ธ IChatClient is registered as Singleton through services.AddChatClient(...) and the entire ChatClientBuilder middleware pipeline is wired in one place.
๐๏ธ ChatClientAgent and AgentSessionStore are both Singleton; AgentSession is resolved per conversation from the store, not registered in DI.
๐๏ธ Prompt versions and model selection bind to IOptionsMonitor<T> and the dependent values are read from CurrentValue on each invocation, not captured at registration.
๐๏ธ Secrets come from a configuration provider that overlays at runtime, Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault, never from a checked in file or a CI environment variable.
๐๏ธ Scope validation is enabled in every environment, not only Development, via UseDefaultServiceProvider(opts => opts.ValidateScopes = true) on the host.
๐๏ธ The OpenTelemetry pipeline subscribes both the application's own ActivitySource and the Microsoft.Extensions.AI source through .AddSource(...) on the tracer provider builder.
๐๏ธ The OTel sampler ratio is production tuned; streaming endpoints carry an explicit per event budget cap.
๐๏ธ User content redaction runs before the messages reach GetResponseAsync and before any span tag carries them.
๐๏ธ The composition root in Program.cs reads top to bottom and answers every "where does X live" question without opening another file.
๐๏ธ The unified web host versus worker plus gateway decision is documented as an operational choice with a written trigger for when the split becomes worth its operational cost.
๐๏ธ Every architectural decision in the reference architecture is defensible against the question "what does the code review say if you got this wrong": the answer is a specific failure mode from the list above.
The Architect's Mental Model
The .NET agentic service is a generic host with the four layer cut, the five DI seams, and one composition root the team can read together. The framework choices, Microsoft Agent Framework over Semantic Kernel, Microsoft.Extensions.AI as the substrate, ASP.NET Core for the HTTP surface, IHostedService for background work, OpenTelemetry for observability, are decisions the architect makes once and lives with for a release cycle. The day to day work of operating the service is configuration changes, prompt version rolls, and model swaps, none of which should require a code change.
The well architected reference is not a starter template. It is the smallest set of files a senior architect can defend in a one hour review. The discipline is the same discipline that has carried Microsoft.Extensions.* for a decade: abstract what changes, register what survives, monitor what moves.
Abstract what changes, register what survives, monitor what moves. Everything else is just
Program.cswith the lights on.




