<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Maab Tech]]></title><description><![CDATA[Maab Tech]]></description><link>https://maab.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Maab Tech</title><link>https://maab.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 01 Sep 2026 00:52:28 GMT</lastBuildDate><atom:link href="https://maab.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Closer look at Clean Architecture in .NET & AI]]></title><description><![CDATA[The foundations of a solid .NET system are Clean Architecture, OOP, and design patterns. Every .NET engineer knows how expensive design mistakes become later; Models change, SDKs change. The question ]]></description><link>https://maab.hashnode.dev/closer-look-at-clean-architecture-in-net-ai</link><guid isPermaLink="true">https://maab.hashnode.dev/closer-look-at-clean-architecture-in-net-ai</guid><category><![CDATA[C#]]></category><category><![CDATA[Clean Architecture]]></category><category><![CDATA[AI]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[design principles]]></category><dc:creator><![CDATA[Maab Zakour]]></dc:creator><pubDate>Thu, 27 Aug 2026 17:20:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a90644720bce8b5d756cac9/39da7f52-03b9-445a-b2ac-c59294db4a7a.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The foundations of a solid .NET system are <strong>Clean Architecture, OOP, and design patterns</strong>. Every .NET engineer knows how expensive design mistakes become later; Models change, SDKs change. The question I kept in my mind while learning AI integration was:</p>
<p><strong>Where do Clean Architecture and design patterns sit when the dependency is an LLM?</strong></p>
<p>Most of what I read showed how to direct integrate. New client, send a prompt, deal with LLM and print the text. What was absent what is about extending the system? you drop the same call into a real service and the usual questions show up. Where does the client live? What happens when you leave the local models on your machine and move to Anthropic or Azure OpenAI? How do you test a use case without a live endpoint? Who owns the prompt: the controller, the handler, or a string in a helper?</p>
<p>We already have answers for this. In Clean Architecture an external system sits behind a port. The domain does not know SQL Server from Postgres. It should not know OllamaSharp from <code>IChatClient</code> either. Some chat APIs take a session id and keep the transcript. Some want the full history on every request. Either way, conversation state, instructions, and retrieved text are application concerns. The model is infrastructure.</p>
<p>This article is how I started mapping the usual .NET habits onto that dependency: a chat port so use cases do not take a vendor SDK, a builder where prompts are not interpolated in the handler, and decorators so cache and telemetry are not copied into every call. I am not writing about training models. I am writing about keeping AI features maintainable in a .NET system. core principles which should take care about to build a solid foundations system.</p>
<h2>Encapsulation as a critical point</h2>
<p>An LLM is not special infrastructure. Treat it like a database, a payment gateway, or an SMS provider:</p>
<ul>
<li><p>it lives outside your domain</p>
</li>
<li><p>it can fail, throttle, or change shape</p>
</li>
<li><p>you talk to it through abstractions</p>
</li>
<li><p>your business rules stay in C#, not in the model</p>
</li>
</ul>
<p>If you drop vendor clients straight into controllers, you will pay for it later. Same mistake as scattering SQL everywhere.</p>
<p><strong>Encapsulate the LLM behind a clear contract.</strong> Consumers ask for a result. They should not know the provider or the SDK.</p>
<h2>A minimal mental model for .NET developers</h2>
<p>You do not need ML theory. You need enough context to make design decisions.</p>
<p>From a software point of view, an LLM is a <strong>non-deterministic text engine</strong>:</p>
<ul>
<li><p><strong>Memory:</strong> some APIs take a session or conversation id and keep the transcript for you. Others expect you to send the full history on every call. Either way that state is a conversation store (yours or the vendor's), not the model itself. A session id is not a reason to skip a port.</p>
</li>
<li><p><strong>Non-deterministic:</strong> the same prompt can yield a different completion. Do not treat the raw string as a domain fact.</p>
</li>
<li><p><strong>Tokens:</strong> cost and rate limits are measured in tokens, not characters. Prompt size, history windows, and retrieved context are budget decisions.</p>
</li>
</ul>
<p>You will not implement a tokenizer. You <em>will</em> design prompts, history, and session handling around these limits.</p>
<p><strong>Focus on what changes your design:</strong> where conversation state lives, and a token budget you must respect.</p>
<h1>Put AI in the infrastructure layer</h1>
<p>LLMs talk to the outside world. They are not the source of business truth.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a90644720bce8b5d756cac9/81a6f526-0173-4d11-893b-02fcac7dd14e.webp" alt="clean-architecture-ai-dotnet" style="display:block;margin:0 auto" />

<p>The application layer never knows which provider answered. It depends on <code>IChatService</code>. The domain depends on neither the application layer nor <code>IChatService</code>.</p>
<ul>
<li>Chat is a use-case concern. Domain stays business rules.</li>
</ul>
<p>Same idea as hiding SQL behind a repository. If use cases take vendor SDKs, switching providers or writing tests becomes a rewrite.</p>
<p>Today you may run <strong>Ollama</strong> locally. Tomorrow the product may need <strong>Anthropic</strong>, <strong>Gemini</strong>, or <strong>Azure OpenAI</strong> for scale, compliance, or quality. If every use case references <code>OllamaApiClient</code>, that switch is a rewrite. If the provider lives in one infrastructure class behind <code>IChatService</code>, the change is registration and configuration. Use cases, prompt building, and domain rules stay the same. Tests mock the port. No model required.</p>
<p>Microsoft.Extensions.AI gives you <code>IChatClient</code>. That is still the SDK. Your use cases should not take it. They take <code>IChatService</code>, a small interface you own. <code>ChatService</code> is the adapter: it takes your prompt, calls the SDK, and returns text your app can use.</p>
<pre><code class="language-plaintext">ChatUseCase  =&gt;  IChatService  =&gt;  ChatService  =&gt;  IChatClient
</code></pre>
<pre><code class="language-csharp">public interface IChatService
{
    Task&lt;ChatAnswer&gt; GetResponseAsync(ChatPrompt prompt, CancellationToken ct);
}

public sealed class ChatService(IChatClient client) : IChatService
{
    public async Task&lt;ChatAnswer&gt; GetResponseAsync(ChatPrompt prompt, CancellationToken ct)
    {
        var response = await client.GetResponseAsync(prompt.ToMessages(), cancellationToken: ct);
        return new ChatAnswer(response.Text);
    }
}
</code></pre>
<p>Register the adapter. To switch providers you change <code>CreateVendorClient</code>. The use case does not change.</p>
<pre><code class="language-csharp">builder.Services.AddChatClient(CreateVendorClient(builder.Configuration));
builder.Services.AddScoped&lt;IChatService, ChatService&gt;();
</code></pre>
<p><strong>Depend on interfaces, not vendor SDKs.</strong></p>
<h2>How prompt building maps to a design pattern</h2>
<p>A production prompt is not an interpolated string in a use case. It is a structured object:</p>
<ul>
<li><p>system instructions</p>
</li>
<li><p>user profile or tenant data</p>
</li>
<li><p>conversation history (user <em>and</em> assistant turns)</p>
</li>
<li><p>retrieved documents, if any</p>
</li>
<li><p>the current user message</p>
</li>
</ul>
<p>String concatenation in the use case makes prompts hard to test, hard to version, and easy to break.</p>
<p>History must keep assistant turns as assistant. If you send the model's old replies as user messages, the next answer gets worse.</p>
<p>That is the <strong>Builder pattern</strong>. The use case names the pieces. <code>Build()</code> owns order: system, retrieved context, history, then the user message.</p>
<pre><code class="language-csharp">var prompt = new PromptBuilder()
                        .WithSystemInstructions(rules)
                        .WithConversationHistory(history)
                        .WithRetrievedContext(chunks)
                        .WithUserMessage(query)
                        .Build();
</code></pre>
<p>Create a <strong>new builder per request</strong>. Do not inject a mutable builder as a singleton. Leftover history from the previous call will leak into the next one.</p>
<p><code>ChatPrompt</code> is an ordered list of turns. You can log it, snapshot it in a test, and send it through the port. The builder has a slot for retrieved text. It does not do retrieval.</p>
<p>The use case names the pieces, then calls the port:</p>
<pre><code class="language-csharp">public sealed class ChatUseCase(IChatService chat)
{
    public Task&lt;ChatAnswer&gt; ExecuteAsync(ChatRequest request, CancellationToken ct)
    {
        var prompt = new PromptBuilder()
                        .WithSystemInstructions(request.SystemInstructions)
                        .WithConversationHistory(request.History)
                        .WithUserMessage(request.UserQuery)
                        .Build();

        return chat.GetResponseAsync(prompt, ct);
    }
}
</code></pre>
<h2>Cross-cutting concerns with Decorator</h2>
<p>Tomorrow you need caching, Then telemetry, Then rate limiting. You should not edit every model call.</p>
<p>Here is the ideal use of the <strong>Decorator pattern</strong>, or the middleware style from <code>Microsoft.Extensions.AI</code>:</p>
<pre><code class="language-plaintext">IChatClient
    =&gt; cache
        =&gt; telemetry
            =&gt; rate limiter
                =&gt; vendor client
</code></pre>
<p>Builder builds the prompt. The port sends it. Cache, telemetry, and rate limits wrap the call. You do not edit <code>ChatUseCase</code> or <code>ChatService</code> when you add cache. You wrap the client at registration:</p>
<pre><code class="language-csharp">builder.Services.AddChatClient(CreateVendorClient(builder.Configuration))
                .UseDistributedCache()
                .UseOpenTelemetry();
</code></pre>
<p><code>UseDistributedCache</code> and <code>UseOpenTelemetry</code> are decorators. Each one is still an <code>IChatClient</code>. <code>ChatService</code> keeps taking <code>IChatClient</code>. The use case does not know cache exists.</p>
<h2>Conclusion</h2>
<p>AI does not replace architecture. It stresses it. Calling an LLM is development; Designing a system that stays maintainable around an LLM is software engineering. Put the model behind an infrastructure port. Compose prompts with a builder instead of string soup in use cases. Stack cache and telemetry with decorators so those concerns do not leak into use cases. Keep the provider in one registration so switching does not rewrite the application.</p>
<p>Get the boundaries right first. The fancy codes are easier to rebuild than a tangled production codebase.</p>
]]></content:encoded></item></channel></rss>