Skip to main content

Intelligent Auditing with Microsoft.Extensions.AI and Google Gemini in .NET 10

2026-02-0710 min
IA.NETClean CodeArquitectura

If you've been following this project's story, you'll know that ExpenseReimbursementContext is my testing ground for exploring .NET 10's latest features in a practical way. In the previous article we built honest endpoints with Minimal APIs, domain validations with ErrorOr, and a clean architecture that allowed us to grow without pain. Well, today it's time to take a step further: we're going to add artificial intelligence to the expense flow.

The idea is simple but powerful: before an employee submits an expense for approval, we want a virtual auditor to analyze the description, amount, and currency, and return a suggested category, a compliance score, and possible alerts. And to do so, we're going to use Microsoft.Extensions.AI with the Google GenAI adapter (Gemini).

Why Microsoft.Extensions.AI?

If you've been in the .NET ecosystem for a while, you've surely seen the pattern: Microsoft creates unified abstractions that allow you to change providers without touching your business code. They did it with logging, with dependency injection, with caching... and now they've done it with AI.

Microsoft.Extensions.AI gives you an IChatClient interface that is provider-agnostic. It doesn't matter if under the hood you're using OpenAI, Azure AI, Anthropic, or Google Gemini: your application code only talks to IChatClient. If tomorrow you want to change model or provider, you only change the dependency container configuration. Your domain, use cases, and tests don't notice.

This fits perfectly with what we've been building in this project: separating infrastructure decisions from business logic.

The Pieces of the Puzzle

For this feature we need four NuGet packages:

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 is Google's official SDK for .NET, which already brings an IChatClient implementation through the .AsIChatClient() extension method. This means we don't need any additional bridge package: Google has already done the integration work with Microsoft's abstractions.

Starting from the Domain: The Auditor Abstraction

Faithful to the architecture we'd been following, the first thing I did was define what the domain needs to know about auditing, without worrying yet about how it's implemented. That's how IExpenseAuditor was born:

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

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

Notice a couple of details. The AuditExpense record models exactly what we expect from the LLM: a suggested category, a compliance score (from 0.0 to 1.0), a list of flags or alerts, and the model's reasoning. And the interface returns ErrorOr<AuditExpense>, following the same pattern we use in the rest of the project for handling errors without exceptions.

The domain doesn't know and doesn't care that under the hood there's an AI model. It only knows something exists that audits expenses and can fail.

The Use Case: Orchestrating Without Coupling

The AuditExpenseUseCase is deliberately simple. It finds the expense in the repository, and if it exists, passes it to the 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", "The expense does not exist.");
        }

        var auditResult = await auditor.Expense(expense);

        return auditResult;
    }
}

There's no reference to IChatClient, to Google, or to any AI model here. The use case speaks exclusively in domain terms. If tomorrow we decide the auditing is done by an external HTTP service or a rules engine, this code doesn't change.

The AI Implementation: AIExpenseAuditor

This is where the magic of Microsoft.Extensions.AI makes sense. The AIExpenseAuditor lives in the infrastructure layer and receives an IChatClient via dependency injection:

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();
    }
}

There are several things I like about how this turns out. First, we use Expense's Deconstruct to cleanly extract the fields we need. Second, the system prompt is designed to make the model act as a specialized financial auditor, with well-defined categories and criteria. And third, GetResponseAsync<AuditExpense> is the crown jewel: we ask the LLM to return directly a typed object. Microsoft.Extensions.AI takes care of generating the JSON schema from the record and deserializing the response. No manual string parsing.

Another important detail: we don't send sensitive employee information to the model. Only the expense context (description, amount, and currency). This is a conscious privacy decision.

Configuring the Gemini Client

In Program.cs, the configuration is minimal thanks to the abstractions:

c#
builder.Services.AddChatClient(_ =>
{
    var apiKey = builder.Configuration["AiSettings:GeminiKey"];
    if (string.IsNullOrEmpty(apiKey))
    {
        throw new InvalidOperationException(
            "The Gemini API key is not configured in AiSettings:GeminiKey");
    }

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

We create a Google GenAI Client with the API key (which we store in User Secrets to avoid committing it), and convert it to IChatClient with .AsIChatClient(). The AddChatClient method registers it in the DI container so any component that depends on IChatClient receives it automatically.

The API key is configured in appsettings.json as a placeholder and overridden with User Secrets in development:

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

And in the expenses module, we register the auditor implementation:

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

If tomorrow we wanted to switch to OpenAI, we'd only touch Program.cs to instantiate an OpenAIClient instead of Google.GenAI.Client. Everything else, from the use case to the tests, would continue working the same.

The Endpoint: Exposing the Audit

The endpoint follows the pattern we already had with submit. A POST without body (because AI inference is a costly operation that shouldn't be cached like a GET):

c#
public static class AuditExpenseEndpoint
{
    public static void MapAuditExpenseEndpoint(this IEndpointRouteBuilder app)
    {
        app.MapPost("{id:guid}/audit", HandleAsync)
            .WithName("AuditExpense")
            .WithSummary("Audits an expense")
            .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
                }
            )
        );
    }
}

The same ErrorOr Match we use in the submit endpoint. Consistency throughout the project.

Testing Without Calling the AI

This is probably the part I like most about having invested in the abstraction. Thanks to IExpenseAuditor, we can test all the use case logic without making any real calls to 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);
    }
}

We mock IExpenseAuditor with NSubstitute and control exactly what it returns. We can test the happy path, verify the auditor isn't called if the expense doesn't exist, test that AI service errors are propagated, and validate flags for suspicious expenses. All this without spending a single token and with tests that run in milliseconds.

What I Take Away from This Iteration

There are several reflections I'm left with from having integrated AI into a project with clean architecture:

Abstractions matter, a lot. IChatClient is not just a pretty interface. It's what allows our domain and our tests not to depend on a specific AI provider. It's the same dependency inversion principle as always, applied to a new context.

AI is an infrastructure detail. Just like the database or file system. The domain defines what it needs (audit an expense), and infrastructure decides how (by calling Gemini). This isn't new, but it's easy to forget when the temptation to put IChatClient directly in the use case is strong.

Structured Outputs changes the game. Being able to tell the LLM "return me an AuditExpense" and receive a typed object instead of a string is a before and after. It eliminates an entire category of bugs related to manual response parsing.

Testing with AI doesn't have to be a nightmare. If your design is correct, unit and integration tests don't need to touch real AI. The auditor mock gives us full control over scenarios, including service failures.

What's Coming Next?

The project continues to grow. We still have the receipt attachment endpoint, the approval flow with the review endpoint, and likely exploring more .NET 10 features along the way. If you want to follow the progress or take a look at the complete code, the repository is on GitHub.

See you in the next iteration.

Sources