Post

Module 7 — Separation of concerns: where AI touches your architecture

Prompt & Tool Design for .NET Teams · Part 2 — Semantic Kernel in .NET · Module 7 of 12

This is the module that decides whether you enjoy this codebase in a year. Left unchecked, AI code spreads: a model ID hard-coded in a controller here, a prompt concatenated into a service there, and suddenly “swap the model” is a three-day refactor. The fix is boring and effective: put the boundaries in on purpose, now, while the codebase is still small enough to argue about.

Objective

Define the architectural boundaries so AI code stays in its lane instead of seeping through the whole solution.

Read (~12 min)

  • Microsoft.Extensions.AI — the IChatClient abstraction Semantic Kernel (SK) itself is converging on.
  • Semantic Kernel filters, for cross-cutting concerns like logging, personally identifiable information (PII) redaction, and approval gates.

The layering to adopt

Discuss this as a team. The disagreements are the useful part.

  • Domain — owns business logic and the inner services your plugins wrap. Never holds prompts, model IDs, or SK types.
  • AI orchestration — owns kernel setup, plugins (thin adapters over domain services), prompt templates as embedded resources, and filters. Never holds business rules.
  • Infrastructure — owns model IDs, region, credentials, and guardrail Amazon Resource Names (ARNs); it is all configuration, and hard-codes nothing.

Lab (~18 min)

Refactor Modules 46:

  • prompts move out of string literals into /Prompts/*.md embedded resources;
  • the model ID moves into appsettings;
  • add one IFunctionInvocationFilter that logs every tool call with its arguments and duration.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;

public sealed class LoggingFilter(ILogger<LoggingFilter> logger)
    : IFunctionInvocationFilter
{
    public async Task OnFunctionInvocationAsync(
        FunctionInvocationContext context,
        Func<FunctionInvocationContext, Task> next)
    {
        var sw = Stopwatch.StartNew();
        logger.LogInformation("Invoking {Plugin}.{Function}",
            context.Function.PluginName, context.Function.Name);

        await next(context); // the function actually runs here

        logger.LogInformation("Invoked {Plugin}.{Function} in {Elapsed}ms",
            context.Function.PluginName, context.Function.Name, sw.ElapsedMilliseconds);
    }
}

Register it through dependency injection (DI) on the kernel builder:

1
kernelBuilder.Services.AddSingleton<IFunctionInvocationFilter, LoggingFilter>();

Then write a one-page Architecture Decision Record (ADR) — “How AI components integrate with our solution” — and commit it as module-07/adr-001-ai-boundaries.md. Future hires will read this instead of asking you.

Done when

Changing model or provider is a config change, and deleting the AI orchestration project doesn’t break the domain layer’s compile. If the domain project won’t build without the AI project, the boundary is on paper only.


Series navigation

This post is licensed under CC BY 4.0 by the author.