SKILL: Layered design for separation of concerns
What’s it all about
I start with the end goal in mind. What i really need in the design phase is a clear set of interfaces that allow the parts of the software to be able to be iterated upon in isolation. A simple expression of this is:
agent layer ──▶ business layer ──▶ storage layer
(translates) (domain logic) (persistence, injected)
Importantly the business layer does not depend on anything. For what it needs, it provides interfaces for the other layers.
ToDo - security, testing, refactoring
At this point the general code architecture is addressed but specific cross cutting concerns are a TODO. Specifically, looking at the source code I can see repeated code. I will address this as a priority and then move on to security hardening and iterating over the various testing phases.
I also don’t like the Microsoft AIAgent being exposed in the interface of the Agent layer. It ties to the implementation as Microsoft Agent Framework.
Testing has been touched on in this current version but i want a clear progression of test scopes including the ability to run every test in a docker compose - something a ci pipeline would possibly include before a deploy except I fear this may be a bit too much for most runners due to the need to startup a local SLM.
The full document, at the time of writing looks like:
separation of concerns
Layered Design for Separation of Concerns
A three-layer pattern for building AI-agent features cleanly. The rule of thumb: each layer only knows about the layer directly beneath it, and nothing below the agent layer knows that an AI exists.
agent layer ──▶ business layer ──▶ storage layer
(translates) (domain logic) (persistence, injected)
The three layers
1. Business layer — Business/ (namespace …Business)
- A class (
*Service) with the business functionality: constructs domain objects (with sensible defaults), enforces business rules, orchestrates persistence through the injected store. - A validation mechanism (
*Validator) for the input parameters: required fields, ranges, dates (e.g. reject future dates), defined enum values. - A result type (
*Result) for operation outcomes: success + domain object, or a plain-language error. No emojis, no Markdown, noAIFunction— this layer returns domain objects only.
2. Data storage layer — Storage/ (namespace …Storage)
- An interface (
I*Store/I*Repository) plus a concrete implementation (in-memory, EF Core, SQL, …). - Injected into the business object’s constructor — the business class never news up its own store.
- Pure persistence. No business rules (no future-date checks, no amount validation), no AI concepts. Swapping the implementation must not touch the other layers.
3. Agent-specific layer — Tools/ (namespace …Tools)
- A translation class (
*ToolActions): converts the AI tool’s raw string parameters (dates, ids, enums) into typed values, calls the business object, and formats the domain results into the Markdown/emoji responses the model reads. - A tool-registration class (
*Tools): creates theAIFunctions with a name and a description (viaAIFunctionFactory.Create) that tells the model when to call the tool and what parameters it needs. - All
AIFunction, tool-description, and response-formatting concerns live here.
Workflow: applying this to a new feature
- Model the domain — a shared entity in
Models/(used by all three layers). - Storage layer — write the interface, then a concrete implementation. Make it thread-safe if it is shared across sessions.
- Business layer — result type → validator → service class with the store injected in its constructor. Every business rule goes through the validator.
- Agent layer — one translation method per tool (raw strings in → typed
business calls → formatted responses), registered with
AIFunctionFactory.Create. - Wire it up — register store + service in DI, pass the service into the
agent factory. In this repo the agent project owns the composition root:
// ExpenseAgent/DependencyInjection/ExpenseTrackerServiceCollectionExtensions.cs services.AddSingleton<ITransactionStore, InMemoryTransactionStore>(); // interface (ExpenseLib) → impl (Expense.Infrastructure) services.AddSingleton<TransactionService>(); // business layer (ExpenseLib), store injected by the container services.AddSingleton<AIAgent>(sp => sp.GetRequiredService<AgentFactory>().CreateExpenseTrackerAgent( sp.GetRequiredService<TransactionService>())); // WebApi: builder.Services.AddExpenseTracker(agentConfig); - Test each layer — deterministic tests that do NOT need an LLM or API key:
- Business layer: unit tests against the service (construct, validate, edit, remove).
- Agent layer: invoke the
AIFunctiondirectly with a JSON-like argument dictionary, bypassing the model entirely.
Copy-paste templates
All templates live in templates/ (C# / .NET + Microsoft.Extensions.AI).
Substitute the Transaction* names with your domain:
| Template | File | Replace |
|---|---|---|
| Domain model | templates/domain-model.cs |
Transaction → your entity |
| Storage layer (interface + in-memory impl) | templates/storage-layer.cs |
ITransactionStore, InMemoryTransactionStore |
| Business layer (result + validator + service) | templates/business-layer.cs |
TransactionResult, TransactionValidator, TransactionService |
| Agent layer (tool actions + AIFunction creation) | templates/agent-layer.cs |
TransactionToolActions, TransactionTools |
Reference implementation
This repository implements the pattern split across three projects — the AI-free core in
ExpenseLib, the concrete storage in Expense.Infrastructure, and the agent layer in
ExpenseAgent (which references the other two and composes everything):
- Domain + business:
src/ExpenseAgent/ExpenseLib/—Models/Transaction.cs,Business/TransactionService.cs,Business/TransactionValidator.cs,Business/TransactionResult.cs(namespaceExpenseLib.*, no AI references) - Storage interface:
src/ExpenseAgent/ExpenseLib/Storage/ITransactionStore.cs - Storage implementation:
src/ExpenseAgent/Expense.Infrastructure/InMemoryTransactionStore.cs - Agent:
src/ExpenseAgent/ExpenseAgent/Tools/TransactionToolActions.cs,src/ExpenseAgent/ExpenseAgent/Tools/TransactionTools.cs - Wiring:
AgentFactory.CreateExpenseTrackerAgent(TransactionService)+ the composition rootExpenseAgent/DependencyInjection/ExpenseTrackerServiceCollectionExtensions.cs(AddExpenseTracker), called fromExpenseAgent.WebApi/Startup.cs - Tests:
ExpenseAgent.Tests/TransactionServiceTests.cs(business layer),ExpenseAgent.Tests/TransactionToolTests.cs(agent layer, no LLM)
Rules and anti-patterns
- ❌ Agent tool methods reaching straight into the store — always go through the business object.
- ❌ Business layer returning
❌/✅/Markdown strings — return a*Result; the agent layer formats. - ❌ Validation (future dates, positive amounts, required fields) living in the
agent layer — it belongs in the
*Validator, behind the business object. - ❌ Business layer referencing
AIFunction,Microsoft.Extensions.AI, or tool descriptions. - ❌ A store without an interface — the business object must receive storage by injection.
- ❌ Passing raw, unparsed strings into the business layer. Parsing
(string →
DateTime/Guid/enum) is translation and lives in the agent layer; validating parsed values against business rules lives in the business layer.
Quick check before you call it done
- Does the business class construct and validate without any AI types?
- Is the store injected via constructor, not instantiated inside the business class?
- Do the agent tool actions only parse, translate, and format?
- Do deterministic tests cover the business rules without an LLM or API key?
- Can the storage implementation be swapped without touching the business or agent layers?