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.
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.
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);
}
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>:
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.
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.
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:
AgentFactoryAIAgent: loads the skill, resolves tools, wires content safety and OTel, applies function invocation limits. Consumers call factory.Create(skillId); everything else is internal.ChatClientFactoryClientType — Azure OpenAI, OpenAI, AI Foundry, or Azure AI Inference. Decorates with retries, timeouts, observability, content safety.AgentExecutionContextFactory
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.
// 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.
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.