Clean Architecture in .NET for Generative AI Systems: A Practical Guide
Generative Artificial Intelligence has burst into software architectures like a hurricane. LLM and diffusion models open new frontiers in value creation, but they introduce a factor that frightens backend engineers: stochasticity.
Unlike a deterministic calculation, an AI model can respond differently to the same input. If we don't control this uncertainty, the business logic becomes coupled to the idiosyncrasies of an AI vendor (vendor lock-in), and our critical system becomes fragile and unpredictable.
In this article, we will see how to tame AI using Clean Architecture in .NET (C#), establishing clear boundaries to protect our domain model.
1. The Challenge: AI in Critical Systems
When integrating AI into a monolith or legacy microservice, the most common (and dangerous) pattern is to invoke the AI model's API directly from the business logic or from the HTTP controller.
What happens when:
- Does the AI provider change its API signature?
- Does the model return malformed JSON (hallucination)?
- Do you need to run Unit Tests without incurring API costs?
- Do you want to switch to a local model (ONNX) due to latency or privacy concerns?
If your business logic knows about the existence of “OpenAI”, “Anthropic”, or “ONNX”, you have a serious abstraction leak.
2. Clean Architecture Fundamentals for AI
Clean Architecture, promoted by Robert C. Martin, posits that dependencies can only point inward: from implementation details toward the pure business rules.
When applying this to an AI system, the layers are distributed as follows:
- Domain Layer (Core): Pure entities and invariant rules. There is no AI here, only pure C#. Defines what we need (e.g.,
IInferenceResult). - Application Layer (Use Cases): Orchestrates the workflow. Defines the Ports (interfaces) that represent AI services (e.g.,
IModelInferencePort). - Infrastructure Layer (Adapters): Implements the ports. This is where the OpenAI SDKs, HTTP clients, ONNX Runtime, etc., are located.
- Presentation Layer (Input): REST APIs, gRPC, RabbitMQ Workers receiving client requests.
Architecture Diagram (C4 Container)
C4Container
title Arquitectura de IA Desacoplada (.NET)
Person(client, "Cliente / Sistema Frontend")
System_Boundary(core, "Clean Architecture Backend (.NET)") {
Container(api, "API REST", "C# .NET", "Recibe peticiones HTTP")
Container(app, "Capa de Aplicación", "C# .NET", "Casos de Uso y Puertos")
Container(domain, "Capa de Dominio", "C# .NET Puro", "Lógica y DTOs inmutables")
Container(infra, "Capa de Infraestructura", "C# .NET", "Adaptadores (ONNX, OpenAI, SQL)")
}
SystemDb(db, "SQL Server / Redis", "Estado y Caché")
System_Ext(openai, "OpenAI API", "Modelo Cloud")
System_Ext(onnx, "ONNX Runtime", "Modelo Local (Edge)")
Rel(client, api, "Llama API (JSON)")
Rel(api, app, "Invoca Caso de Uso")
Rel(app, domain, "Usa entidades de")
Rel(app, infra, "Define puertos implementados por")
Rel(infra, openai, "Inferencia HTTP")
Rel(infra, onnx, "Inferencia Local")
Rel(infra, db, "Persistencia/Caché")
3. Practical Case: Decoupled Inference Service
See how this translates to C# code. Our goal is to analyze the sentiment of a text and classify its urgency, but our domain should not know whether we are using an LLM in the cloud or an ONNX model locally.
The Port in the Application Layer
We define the interface that our infrastructure must implement. We use record to guarantee immutable DTOs.
// Domain / Application Layer
public record InferenceRequest(string TextContext);
public record InferenceResult(float Confidence, string Classification);
public interface ITextClassificationPort
{
Task<InferenceResult> ClassifyAsync(InferenceRequest request, CancellationToken ct);
}
The Use Case (Application Layer)
The use case consumes the interface without knowing anything about its implementation.
// Application Layer
public class AnalyzeTextUseCase
{
private readonly ITextClassificationPort _classificationPort;
private readonly IBusinessRuleValidator _validator;
public AnalyzeTextUseCase(
ITextClassificationPort classificationPort,
IBusinessRuleValidator validator)
{
_classificationPort = classificationPort;
_validator = validator;
}
public async Task<InferenceResult> ExecuteAsync(string text, CancellationToken ct)
{
// 1. Validaciones previas de negocio
_validator.EnsureValidInput(text);
// 2. Ejecutar inferencia (El caso de uso no sabe qué IA se ejecuta)
var request = new InferenceRequest(text);
var result = await _classificationPort.ClassifyAsync(request, ct);
// 3. Post-validación del output de la IA (Mitigar estocasticidad)
if (result.Confidence < 0.8f)
{
throw new InferenceConfidenceException("Confianza insuficiente para decisión automática.");
}
return result;
}
}
4. Handling Uncertainty: Ports and Adapters
We are now implementing the infrastructure layer. This is where we get into the SDKs, handle retries using Polly, and parse the sometimes unpredictable outputs from the AI.
ONNX Adapter (Local Edge)
// Infrastructure Layer
public class OnnxClassificationAdapter : ITextClassificationPort
{
private readonly InferenceSession _session;
public OnnxClassificationAdapter(string modelPath)
{
// Inicialización pesada en el arranque de la app
_session = new InferenceSession(modelPath);
}
public async Task<InferenceResult> ClassifyAsync(InferenceRequest request, CancellationToken ct)
{
// ... Lógica de tokenización y ejecución del tensor ...
// Este código es feo, pero está aislado del resto del sistema.
// Simulación:
// var outputTensor = _session.Run(inputs);
// var (label, confidence) = ExtractHighestProbability(outputTensor);
return new InferenceResult(0.95f, "URGENTE");
}
}
To switch to a cloud model (e.g., OpenAI), we would simply create a OpenAiClassificationAdapter and change the registration in the Dependency Injection container, without touching a single line of the application or domain layer.
5. Performance and Scalability
AI systems consume resources intensively. For your .NET backend to support massive traffic, consider the following:
- Semantic Cache (Redis): If you process text very similar to a previous one, use a vector database or Redis to return a cached result instead of re-running the inference.
- Asynchronous Processing (RabbitMQ): Never run heavy inference on the HTTP request thread. Queue the task in RabbitMQ and process it in the background using HostedServices or Workers.
- Observability (OpenTelemetry): Log the exact time taken by the AI adapter. Network latency to the AI provider is usually the main bottleneck, and you must be able to trace it without confusing it with issues in your SQL database.
Conclusion
The Clean Architecture design allows us to embrace the power of AI without compromising the stability of the core system. Keeping the boundaries strict ensures that when the next cutting-edge model arrives, its integration will be a matter of adding an adapter, not rewriting your critical system.
Are you building a production AI system?
Architecture is only the first step. I have compiled fundamental practices for infrastructure, monitoring, and deployment in a free resource.
Download AI Architecture Checklist (PDF)If you prefer that I analyze your system directly and develop an action plan for its scalability, schedule a free call with me.
