Saltar al contenido principal

Auditoría Inteligente con Microsoft.Extensions.AI y Google Gemini en .NET 10

2026-02-0710 min
IA.NETClean CodeArquitectura

Si has seguido la historia de este proyecto, sabrás que ExpenseReimbursementContext es mi campo de pruebas para ir explorando las novedades de .NET 10 de forma práctica. En el artículo anterior montamos endpoints honestos con Minimal APIs, validaciones de dominio con ErrorOr, y una arquitectura limpia que nos permitía ir creciendo sin dolor. Pues bien, hoy toca dar un paso más: vamos a añadir inteligencia artificial al flujo de gastos.

La idea es sencilla pero potente: antes de que un empleado envíe un gasto a aprobación, queremos que un auditor virtual analice la descripción, el monto y la moneda, y nos devuelva una categoría sugerida, una puntuación de cumplimiento y posibles alertas. Y para hacerlo, vamos a usar Microsoft.Extensions.AI con el adaptador de Google GenAI (Gemini).

¿Por qué Microsoft.Extensions.AI?

Si llevas tiempo en el ecosistema .NET, seguro que has visto el patrón: Microsoft crea abstracciones unificadas que te permiten cambiar de proveedor sin tocar tu código de negocio. Lo hicieron con el logging, con la inyección de dependencias, con el caching... y ahora lo han hecho con la IA.

Microsoft.Extensions.AI te da una interfaz IChatClient que es agnóstica del proveedor. Da igual si por debajo estás usando OpenAI, Azure AI, Anthropic o Google Gemini: tu código de aplicación solo habla con IChatClient. Si mañana quieres cambiar de modelo o de proveedor, solo cambias la configuración del contenedor de dependencias. Tu dominio, tus casos de uso y tus tests no se enteran.

Esto encaja perfectamente con lo que llevamos construyendo en este proyecto: separar las decisiones de infraestructura de la lógica de negocio.

Las piezas del puzle

Para esta feature necesitamos cuatro paquetes NuGet:

xml
<PackageReference Include="ErrorOr" Version="2.0.1" />
<PackageReference Include="Google.GenAI" Version="0.15.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.2.0" />

Google.GenAI es el SDK oficial de Google para .NET, que ya trae una implementación de IChatClient a través del método de extensión .AsIChatClient(). Esto significa que no necesitamos ningún paquete puente adicional: Google ya ha hecho el trabajo de integración con las abstracciones de Microsoft.

Empezando por el dominio: la abstracción del auditor

Fiel a la arquitectura que veníamos siguiendo, lo primero que hice fue definir qué necesita saber el dominio sobre la auditoría, sin preocuparme todavía de cómo se implementa. Así nació IExpenseAuditor:

c#
public interface IExpenseAuditor
{
    Task<ErrorOr<AuditExpense>> Expense(Expense expense);
}

public record AuditExpense(
    string SuggestedCategory,
    double ComplianceScore,
    List<string> Flags,
    string Reasoning
);

Fíjate en un par de detalles. El record AuditExpense modela exactamente lo que esperamos del LLM: una categoría sugerida, una puntuación de cumplimiento (de 0.0 a 1.0), una lista de flags o alertas, y el razonamiento del modelo. Y la interfaz devuelve ErrorOr<AuditExpense>, siguiendo el mismo patrón que usamos en el resto del proyecto para manejar errores sin excepciones.

El dominio no sabe ni le importa que por debajo haya un modelo de IA. Solo sabe que existe algo que audita gastos y puede fallar.

El caso de uso: orquestando sin acoplamiento

El AuditExpenseUseCase es deliberadamente sencillo. Busca el gasto en el repositorio, y si existe, se lo pasa al auditor:

c#
public class AuditExpenseUseCase(IExpenseRepository expenseRepository, IExpenseAuditor auditor)
{
    public async Task<ErrorOr<AuditExpense>> Execute(Guid id)
    {
        var expense = await expenseRepository.GetById(id);

        if (expense.IsEmpty())
        {
            return Error.NotFound("Expense.NotFound", "El gasto no existe.");
        }

        var auditResult = await auditor.Expense(expense);

        return auditResult;
    }
}

Aquí no hay ninguna referencia a IChatClient, a Google, ni a ningún modelo de IA. El caso de uso habla exclusivamente en términos de dominio. Si mañana decidimos que la auditoría la hace un servicio externo por HTTP o un motor de reglas, este código no cambia.

La implementación con IA: AIExpenseAuditor

Aquí es donde la magia de Microsoft.Extensions.AI cobra sentido. El AIExpenseAuditor vive en la capa de infraestructura y recibe un IChatClient por inyección de dependencias:

c#
public class AIExpenseAuditor(IChatClient chatClient) : IExpenseAuditor
{
    public async Task<ErrorOr<AuditExpense>> Expense(Expense expense)
    {
        var (expenseId, description, amount, currency) = expense;
        var auditMessage = $"""
                            Analyze the following expense and provide your evaluation:
                                    ID: {expenseId}
                                    Description: {description}
                                    Amount: {amount:N2} {currency}
                                    Evaluate if it complies with standard corporate policies
                                    and suggest an appropriate category.
                            """;

        var messages = new List<ChatMessage>
        {
            new(
                ChatRole.System,
                """
                You are a virtual financial auditor specialized in analyzing
                business expenses. Your task is to evaluate expenses and determine:
                1. The most appropriate category (Travel, Meals, Office Supplies,
                   Entertainment, Other)
                2. A compliance score from 0.0 to 1.0 (1.0 = perfectly complies)
                3. Possible flags or alerts (excessive amounts, vague descriptions, etc.)
                4. Brief reasoning of your analysis
                Always respond in structured format according to the provided schema.
                """),
            new(ChatRole.User, auditMessage)
        };

        var response = await chatClient.GetResponseAsync<AuditExpense>(messages);

        return response.Result.ToErrorOr();
    }
}

Hay varias cosas que me gustan de cómo queda esto. Primero, usamos el Deconstruct de Expense para extraer los campos que necesitamos de forma limpia. Segundo, el prompt del sistema está diseñado para que el modelo actúe como un auditor financiero especializado, con categorías y criterios bien definidos. Y tercero, GetResponseAsync<AuditExpense> es la joya de la corona: le pedimos al LLM que nos devuelva directamente un objeto tipado. Microsoft.Extensions.AI se encarga de generar el schema JSON a partir del record y de deserializar la respuesta. Nada de parsear strings a mano.

Otro detalle importante: no enviamos información sensible del empleado al modelo. Solo el contexto del gasto (descripción, monto y moneda). Esto es una decisión consciente de privacidad.

Configurando el cliente de Gemini

En el Program.cs, la configuración es mínima gracias a las abstracciones:

c#
builder.Services.AddChatClient(_ =>
{
    var apiKey = builder.Configuration["AiSettings:GeminiKey"];
    if (string.IsNullOrEmpty(apiKey))
    {
        throw new InvalidOperationException(
            "La clave de API de Gemini no está configurada en AiSettings:GeminiKey");
    }

    var client = new Client(apiKey: apiKey);
    return client.AsIChatClient("gemini-3-flash-preview");
});

Creamos un Client de Google GenAI con la API key (que guardamos en User Secrets para no commitearla), y lo convertimos a IChatClient con .AsIChatClient(). El método AddChatClient lo registra en el contenedor de DI para que cualquier componente que dependa de IChatClient lo reciba automáticamente.

La API key se configura en appsettings.json como placeholder y se sobreescribe con User Secrets en desarrollo:

json
{
  "AiSettings": {
    "GeminiKey": "YOUR_KEY_HERE"
  }
}

Y en el módulo de gastos, registramos la implementación del auditor:

c#
public static void AddExpensesModule(this IServiceCollection services)
{
    services.AddTransient<SubmitExpenseUseCase>();
    services.AddTransient<AuditExpenseUseCase>();
    services.AddTransient<IExpenseRepository, InMemoryExpenseAdapter>();
    services.AddTransient<IExpenseAuditor, AIExpenseAuditor>();
}

Si mañana quisiéramos cambiar a OpenAI, solo tocaríamos el Program.cs para instanciar un OpenAIClient en lugar de Google.GenAI.Client. Todo lo demás, desde el caso de uso hasta los tests, seguiría funcionando igual.

El endpoint: exponiendo la auditoría

El endpoint sigue el patrón que ya teníamos con el submit. Un POST sin cuerpo (porque la inferencia de IA es una operación costosa que no debería cachearse como un GET):

c#
public static class AuditExpenseEndpoint
{
    public static void MapAuditExpenseEndpoint(this IEndpointRouteBuilder app)
    {
        app.MapPost("{id:guid}/audit", HandleAsync)
            .WithName("AuditExpense")
            .WithSummary("Audita un gasto")
            .Produces(StatusCodes.Status200OK)
            .Produces(StatusCodes.Status404NotFound)
            .Produces(StatusCodes.Status400BadRequest);
    }

    private static async Task<IResult> HandleAsync(
        Guid id,
        [FromServices] AuditExpenseUseCase useCase)
    {
        var result = await useCase.Execute(id);

        return result.Match(
            Results.Ok,
            errors => Results.Problem(
                detail: string.Join(", ", from error in errors select error.Description),
                statusCode: errors.First().Type switch
                {
                    ErrorType.NotFound => StatusCodes.Status404NotFound,
                    ErrorType.Validation => StatusCodes.Status400BadRequest,
                    _ => StatusCodes.Status500InternalServerError
                }
            )
        );
    }
}

El mismo Match de ErrorOr que usamos en el endpoint de submit. Consistencia en todo el proyecto.

Testeando sin llamar a la IA

Esta es probablemente la parte que más me gusta de haber invertido en la abstracción. Gracias a IExpenseAuditor, podemos testear toda la lógica del caso de uso sin hacer ninguna llamada real a Gemini:

c#
public class AuditExpenseUseCaseShould
{
    private readonly IExpenseRepository expenseRepository;
    private readonly IExpenseAuditor auditor;
    private readonly AuditExpenseUseCase useCase;

    public AuditExpenseUseCaseShould()
    {
        expenseRepository = Substitute.For<IExpenseRepository>();
        auditor = Substitute.For<IExpenseAuditor>();
        useCase = new AuditExpenseUseCase(expenseRepository, auditor);
    }

    [Fact]
    public async Task return_the_audit_result_for_an_existing_expense()
    {
        var expense = Expense.ADraft()
            .WithAmount(150.00)
            .WithCurrency("EUR")
            .WithDescription("Business lunch with client")
            .Build();
        var expectedAudit = new AuditExpense("Meals", 0.95, [], "Valid business expense");

        expenseRepository.GetById(expense.Id).Returns(expense);
        auditor.Expense(expense).Returns(expectedAudit);

        var result = await useCase.Execute(expense.Id);

        Assert.False(result.IsError);
        Assert.Equal(expectedAudit, result.Value);
    }

    [Fact]
    public async Task not_call_the_auditor_when_the_expense_does_not_exist()
    {
        var expenseId = Guid.NewGuid();
        expenseRepository.GetById(expenseId).Returns(Expense.NotFound());

        await useCase.Execute(expenseId);

        await auditor.DidNotReceive().Expense(Arg.Any<Expense>());
    }

    [Fact]
    public async Task propagate_auditor_failure_when_audit_cannot_be_completed()
    {
        var expense = Expense.ADraft()
            .WithAmount(100.00)
            .WithCurrency("EUR")
            .WithDescription("Team dinner")
            .Build();

        expenseRepository.GetById(expense.Id).Returns(expense);
        auditor.Expense(Arg.Any<Expense>())
            .Returns(Error.Failure(
                "Auditor.Unavailable",
                "The audit service is temporarily unavailable"));

        var result = await useCase.Execute(expense.Id);

        Assert.True(result.IsError);
        Assert.Equal("Auditor.Unavailable", result.FirstError.Code);
    }
}

Hacemos mock de IExpenseAuditor con NSubstitute y controlamos exactamente qué devuelve. Podemos testear el camino feliz, verificar que no se llama al auditor si el gasto no existe, probar que se propagan los errores del servicio de IA, y validar los flags de gastos sospechosos. Todo esto sin gastar un solo token y con tests que corren en milisegundos.

Los tests de integración siguen la misma filosofía, pero a nivel de endpoint. Reemplazamos tanto el repositorio como el auditor en el contenedor de DI del WebApplicationFactory:

c#
public class AuditExpenseEndpointShould(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly IExpenseRepository mockRepository = Substitute.For<IExpenseRepository>();
    private readonly IExpenseAuditor mockAuditor = Substitute.For<IExpenseAuditor>();

    [Fact]
    public async Task return_the_audit_result_for_an_existing_expense()
    {
        var expense = Expense.ADraft()
            .WithAmount(100.00)
            .WithCurrency("EUR")
            .WithDescription("Team dinner")
            .Build();
        var auditResult = new AuditExpense("Meals", 0.9, [], "Valid expense");

        mockRepository.GetById(expense.Id).Returns(expense);
        mockAuditor.Expense(Arg.Any<Expense>()).Returns(auditResult);

        var client = CreateClient();
        var response = await client.PostAsync($"/api/expenses/{expense.Id}/audit", null);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var result = await response.Content.ReadFromJsonAsync<AuditExpense>();
        Assert.NotNull(result);
        Assert.Equal("Meals", result.SuggestedCategory);
    }

    private HttpClient CreateClient()
    {
        return factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                var repoDescriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(IExpenseRepository));
                if (repoDescriptor != null) services.Remove(repoDescriptor);

                var auditorDescriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(IExpenseAuditor));
                if (auditorDescriptor != null) services.Remove(auditorDescriptor);

                services.AddSingleton(mockRepository);
                services.AddSingleton(mockAuditor);
            });
        }).CreateClient();
    }
}

Lo que me llevo de esta iteración

Hay varias reflexiones que me quedan de haber integrado IA en un proyecto con arquitectura limpia:

Las abstracciones importan, y mucho. IChatClient no es solo una interfaz bonita. Es lo que permite que nuestro dominio y nuestros tests no dependan de un proveedor concreto de IA. Es el mismo principio de inversión de dependencias de siempre, aplicado a un contexto nuevo.

La IA es un detalle de infraestructura. Igual que la base de datos o el sistema de archivos. El dominio define qué necesita (auditar un gasto), y la infraestructura decide cómo (llamando a Gemini). Esto no es nuevo, pero es fácil olvidarlo cuando la tentación de meter el IChatClient directamente en el caso de uso es grande.

Structured Outputs cambia las reglas del juego. El hecho de poder decirle al LLM "devuélveme un AuditExpense" y recibir un objeto tipado en lugar de un string es un antes y un después. Elimina toda una categoría de bugs relacionados con el parseo manual de respuestas.

Testear con IA no tiene por qué ser un infierno. Si tu diseño es correcto, los tests unitarios y de integración no necesitan tocar la IA real. El mock del auditor nos da control total sobre los escenarios, incluyendo fallos del servicio.

¿Qué viene después?

El proyecto sigue creciendo. Tenemos pendiente el endpoint de adjuntar recibos, el flujo de aprobación con el endpoint de review, y seguramente explorar más features de .NET 10 por el camino. Si quieres seguir el progreso o echar un vistazo al código completo, el repositorio está en GitHub.

Nos vemos en la siguiente iteración.

Fuentes