Chapter 09 · Patterns

Patterns You'll Use Daily

Six patterns show up in nearly every file you'll touch. Internalize these and the codebase starts to feel consistent rather than alien. Each pattern exists because we tried the simpler thing first and regretted it.

i
Looking for the complete catalogue?

This page is the focused intro — six patterns, the ones you'll touch daily. For the exhaustive list (every governance behaviour, every RAG stage, every graph backend, every NuGet and npm dependency, with source paths), see Reference → Patterns & Technologies.

1 · Result<T> for expected failures

Validation errors, missing records, business-rule rejections — these aren't exceptions, they're expected. Throwing for them pollutes the stack and forces callers to try/catch what should be normal flow.

C# · Domain.Common/Result.cs
public sealed record Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }
    public IReadOnlyList<string> ValidationErrors { get; }

    public static Result<T> Success(T value)                  => new(true, value, null, []);
    public static Result<T> Fail(string error)                => new(false, default, error, []);
    public static Result<T> ValidationFailure(IReadOnlyList<string> errors) => new(false, default, null, errors);
}
When to use Result vs. throw

Result — anything the caller might reasonably handle: validation, "not found", auth rejection, business rules. Throw — truly exceptional conditions: malformed config at startup, programming errors, infrastructure outages. As a rule of thumb, if the user could realistically retry and succeed with different input, it's a Result.

The validation behavior we saw in A Message's Journey uses this — it never throws if the command's response type is Result<T>:

C# · pipeline behavior excerpt
if (ResultHelper.TryCreateValidationFailure<TResponse>(errorMessages, out var failureResult))
    return failureResult;            // short-circuit, no throw

throw new ValidationException(failures);  // fallback for non-Result handlers

2 · MediatR pipeline behaviors

Pipeline behaviors wrap every command/query going through MediatR. They're how this codebase keeps handlers focused on their one job while still adding cross-cutting concerns. The registration order matters — behaviors execute in the order registered, with each one having the opportunity to short-circuit.

C# · IPipelineBehavior shape
public sealed class MyBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        // pre-process

        var response = await next();   // call inner behavior / handler

        // post-process

        return response;
    }
}

The agent pipeline behaviors used by this codebase (registered in Application.AI.Common/DependencyInjection.cs, outermost → innermost):

  • UnhandledExceptionBehavior, AmbientRequestScopeBehavior — outermost wrappers: catch-all error boundary and the ambient request scope everything else reads from.
  • AgentContextPropagationBehavior, AgentIdentityResolutionBehavior — establish agent/conversation IDs and resolve the calling identity into ambient context.
  • AuditTrailBehavior — records the request for the tamper-evident audit chain.
  • ContentSafetyBehavior, PromptInjectionBehavior — the safety stack: content moderation and prompt-injection defense on the way in.
  • TokenBudgetBehavior, HookBehavior, RetrievalAuditBehavior — enforce the per-turn token budget, run lifecycle hooks, and audit RAG retrievals.
  • ResponseSanitizationBehavior, ToolOutputCompressionBehavior — post-execution: scrub the response and compress large tool output.
  • KnowledgeExtractionBehavior, WorkEpisodeCaptureBehavior — post-turn, fire-and-forget knowledge/episode capture.
  • PromptUsageTrackingBehavior — records prompt/token usage metrics.
Where tool gating actually happens

Earlier versions of this guide listed GovernancePolicyBehavior and ToolPermissionBehavior as the tool-gating pipeline. Both were removed in PR #90 — they keyed on an IToolRequest marker that nothing implemented, so they never fired. Live tool gating runs on the tool-execution path instead: every agent tool is wrapped by GovernedAIFunction, whose InvokeCoreAsync applies three ambient, opt-in gates in order — IToolInvocationGovernor (authorization), IToolClassificationGate (Purview data-classification DLP), and IProgressEvaluator (spin / no-progress guard) — then executes the tool.

3 · Factories for complex construction

When constructing a service requires multiple dependencies, config lookups, or decoration steps, we use a factory rather than expanding the constructor of every consumer. The two big ones:

AgentFactory
Builds a fully-configured AIAgent: loads the skill, resolves tools, wires content safety and OTel, applies function invocation limits. Consumers call factory.Create(skillId); everything else is internal.
ChatClientFactory
Builds an LLM chat client based on ClientType — Azure OpenAI, OpenAI, AI Foundry, or Azure AI Inference. Decorates with retries, timeouts, observability, content safety.
AgentExecutionContextFactory
Builds the per-turn execution context — conversation IDs, observability session, ambient services.
The rule

Never construct an AIAgent, IChatClient, or AgentExecutionContext directly. Always go through the factory. Otherwise you lose content safety, OTel, function limits, and config consistency — and these are exactly the things tests catch you forgetting.

4 · Immutability everywhere

Almost every Domain type is a record with init-only properties. Collections expose IReadOnlyList<T>. Mutation happens via with expressions that produce new instances.

C# · idiom
// Don't:
skill.Instructions = "new instructions";   // won't compile — init-only

// Do:
var updated = skill with { Instructions = "new instructions" };

This is a huge win in agent code: when multiple turns of a conversation share state, immutability ensures one turn can't accidentally mutate a value another turn is reading.

5 · Keyed DI for extensible registrations

We covered this on Tools & Keyed DI. The same pattern is used for:

  • Tools — keyed by tool name ("file_system").
  • Tool converters — keyed by priority.
  • Rerankers — keyed by strategy ("AzureSemantic", "CrossEncoder", "NoOp").
  • Chat clients — keyed by ClientType.
  • Content providers, state stores, audit stores — anywhere you might have multiple implementations of the same interface coexisting.

6 · Options pattern with IOptionsMonitor

Always inject IOptionsMonitor<AppConfig>, never raw config or IOptions. IOptionsMonitor picks up changes to appsettings.json without restart. IOptions snapshots once at startup.

C# · idiomatic config access
public class MyService(IOptionsMonitor<AppConfig> cfg) : IMyService
{
    private AppConfig Cfg => cfg.CurrentValue;

    public void DoThing()
    {
        var budget = Cfg.Agent.DefaultTokenBudget;
        // ... use it locally; don't cache cross-method
    }
}

The unwritten rules

Functions stay small

Target under 50 lines per function. If a function is doing three distinct things, extract two of them. The exception is composition (e.g. DependencyInjection.cs with dozens of registrations) where the line count is structural.

One class per file

Even small helper classes get their own file. Makes it trivial to find anything via filename, and keeps git diffs clean.

No console.log or Console.WriteLine in library code

Use the injected ILogger<T>. Logs are JSONL structured by default — they flow to file, named pipe, and (in production) Application Insights. Bare Console.WriteLine bypasses all of that and breaks the JSON.

Validate at boundaries, trust the inside

Validate user inputs and external API responses with FluentValidation. Don't defensively re-validate values that came from your own Domain types. Records + init-only fields + validators at the boundary = the inside can trust its inputs.

Test like you mean it

80% coverage minimum on new code. Tests live in src/Content/Tests/<project>.Tests/ — one test project per source project. Prefer real implementations over mocks: use WebApplicationFactory<Program> + in-memory DB for integration tests. See Extending the Harness for the test patterns.


Where to go from here