# One Screen the Architect Can Defend: A Reference Architecture for the .NET Agentic Service

> 🗓️ **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.

```csharp
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.

```json
{
  "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.cs` with the lights on.*

