# Designing an Anti-Corruption Layer Around an LLM in .NET

Usually with An LLM integration the application might create a provider client, assemble a prompt, send a request and deserialise the response. The first feature works, the code is easy to follow and the integration feels contained. Then the provider's types begin moving through the application. Chat messages appear in application services. Model names arrive in configuration objects owned by business features. Token limits influence domain workflows. Tool call responses reach controllers. Prompt templates acquire placeholders for internal entities. Before long, changing an AI provider or even changing the way a model is called requires edits across code that should have no knowledge of either. The problem runs deeper than vendor lock in. An LLM has its own vocabulary, failure modes and view of the operation. The business has another. Allowing those two models to blend creates a boundary where probabilistic provider behaviour gradually shapes application design.

Domain Driven Design has a name for the pattern that prevents this - an anti corruption layer. In a .NET application, that layer can translate a domain specific request into model instructions, execute the inference, validate the response and return a result expressed entirely in application language. The rest of the system never needs to know which SDK supplied the answer, how the prompt was constructed or whether the provider returned a tool call, structured response or ordinary text.

## How the provider model leaks

Most SDKs encourage developers to work in the provider's native abstraction. A request consists of messages, roles, generation settings and a model identifier. A response may contain choices, content blocks, finish reasons, usage details and tool calls. Those abstractions are appropriate inside an infrastructure adapter. They become expensive when they cross into the application.

A service that extracts renewal information from an insurance submission:

```csharp
public sealed class RenewalService(
    ProviderChatClient chatClient,
    IConfiguration configuration)
{
    public async Task<ChatResponse> ExtractAsync(
        Submission submission,
        CancellationToken stopToken)
    {
        var messages = new List<ChatMessage>
        {
            new(SystemRole.Value, configuration["Prompts:Renewal"]),
            new(UserRole.Value, submission.DocumentText)
        };

        return await chatClient.CompleteAsync(
            configuration["Models:Renewal"],
            messages,
            stopToken);
    }
}
```

The service now knows how the provider represents roles, requests and responses. Its caller must understand `ChatResponse`, including which content item contains the useful result. Provider configuration has also become feature configuration, while prompt assembly is mixed with application orchestration. If another feature follows the same pattern, each service develops its own conventions for prompts, validation, retries and response parsing. A future provider migration becomes a large search and replace exercise followed by a longer period of finding the semantic differences the compiler could not detect. An interface named `IAiService` rarely improves this situation. A generic method such as `CompleteAsync(IEnumerable<ChatMessage>)` preserves the provider's interaction model and places a new name in front of it. The dependency direction changes, but the concepts still leak.

## Give the application its own language

The application should ask for the capability it needs. It should not describe the mechanics used to obtain it. For the renewal workflow, that capability could be represented as an interpreter:

```csharp
public interface IRenewalSubmissionInterpreter
{
    Task<RenewalInterpretation> InterpretAsync(
        RenewalSubmission submission,
        CancellationToken stopToken);
}

public sealed record RenewalSubmission(
    Guid SubmissionId,
    string Content,
    DateOnly ReceivedOn,
    string LineOfBusiness);

public sealed record RenewalInterpretation(
    string InsuredName,
    DateOnly? InceptionDate,
    Money? ExpiringPremium,
    IReadOnlyList<InterpretationEvidence> Evidence,
    IReadOnlyList<InterpretationWarning> Warnings);

public sealed record Money(
    decimal Amount,
    string Currency);

public sealed record InterpretationEvidence(
    string Field,
    string SourceText,
    int? PageNumber);

public sealed record InterpretationWarning(
    string Code,
    string Description);
```

These types describe the business operation. They say nothing about messages, tokens, temperature, content blocks or finish reasons. A caller can use this capability without knowing whether it is implemented using one model, several models, deterministic parsing or a human review queue. That freedom is useful even when the provider never changes. Model families evolve, structured output mechanisms change and different use cases benefit from different routes. The application contract can remain stable while the infrastructure behind it moves.

The boundary now has a clear responsibility:

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/428fd570-986a-42b4-889f-b1c3f3441aa6.png align="center")

The application supplies a business request and receives a business response. The anti corruption layer owns every translation required between that contract and the provider.

## Translation belongs inside the adapter

The adapter has more work to do than forwarding a prompt. It translates the input, invokes the provider, maps technical failures, validates the returned structure and converts the accepted response into application types.

```csharp
public sealed class AzureOpenAiRenewalSubmissionInterpreter(
    IChatCompletionClient completionClient,
    IRenewalPromptFactory promptFactory,
    IRenewalResponseParser responseParser,
    IRenewalInterpretationValidator validator,
    ILogger<AzureOpenAiRenewalSubmissionInterpreter> logger)
    : IRenewalSubmissionInterpreter
{
    public async Task<RenewalInterpretation> InterpretAsync(
        RenewalSubmission submission,
        CancellationToken stopToken)
    {
        var request = promptFactory.Create(submission);

        ProviderCompletion response;

        try
        {
            response = await completionClient.CompleteAsync(
                request,
                stopToken);
        }
        catch (ProviderRateLimitException exception)
        {
            throw new AiCapacityException(
                "The renewal interpretation route is temporarily unavailable.",
                exception);
        }
        catch (ProviderContentRejectedException exception)
        {
            throw new SubmissionContentRejectedException(
                submission.SubmissionId,
                exception);
        }

        var candidate = responseParser.Parse(response);
        var validation = await validator.ValidateAsync(
            candidate,
            submission,
            stopToken);

        if (!validation.IsAccepted)
        {
            logger.LogWarning(
                "Renewal interpretation {SubmissionId} was rejected: {Reason}",
                submission.SubmissionId,
                validation.Reason);

            throw new InterpretationRejectedException(
                submission.SubmissionId,
                validation.Reason);
        }

        return candidate.ToApplicationModel();
    }
}
```

The provider types remain inside the adapter and its collaborators. The application sees failures expressed in terms it can act upon. A capacity problem may be retried or routed elsewhere. Rejected submission content may require a different operational path. An interpretation that fails evidence validation may need human review. Preserving those distinctions is more useful than translating every problem into `AiException`.

## Keep prompts behind the boundary

Prompts are executable integration artefacts. They contain model instructions, formatting rules, examples and assumptions about provider behaviour. Allowing application services to build them inline couples business orchestration to an implementation detail that changes frequently.

A prompt factory can own this translation:

```csharp
public interface IRenewalPromptFactory
{
    ProviderCompletionRequest Create(
        RenewalSubmission submission);
}

public sealed class RenewalPromptFactory(
    IOptions<RenewalInferenceOptions> options,
    IRenewalSchemaProvider schemaProvider)
    : IRenewalPromptFactory
{
    private readonly RenewalInferenceOptions _options = options.Value;

    public ProviderCompletionRequest Create(
        RenewalSubmission submission)
    {
        var systemPrompt = """
            Extract renewal information from the supplied submission.
            Return only values supported by the source content.
            Include source evidence for every populated field.
            Do not infer a currency from geography or broker location.
            """;

        var userPrompt = $$"""
            Submission ID: {{submission.SubmissionId}}
            Received: {{submission.ReceivedOn:yyyy-MM-dd}}
            Line of business: {{submission.LineOfBusiness}}

            Source content:
            {{submission.Content}}
            """;

        return new ProviderCompletionRequest(
            Model: _options.Model,
            SystemPrompt: systemPrompt,
            UserPrompt: userPrompt,
            ResponseSchema: schemaProvider.GetSchema(),
            MaximumOutputTokens: _options.MaximumOutputTokens);
    }
}
```

`ProviderCompletionRequest` is still an infrastructure type, which is fine because the factory and client live on the provider side of the boundary. The application contract remains independent of it. Keeping prompt construction here also makes prompt changes easier to review. The team can see that a change belongs to renewal interpretation rather than a shared collection of loosely related prompt strings. Prompt versions, schemas and evaluation datasets can follow the same use case boundary.

## Structured output does not remove the translation layer

Structured output reduces parsing ambiguity, but it does not turn a model response into a domain object. A schema can require an `insuredName`, `currency` and `premium`. It cannot prove that the name came from the correct section of the document, that the currency belongs to the premium or that the value is acceptable under current business rules.

The first deserialised type should therefore represent a provider candidate:

```csharp
internal sealed record RenewalInterpretationCandidate(
    string? InsuredName,
    DateOnly? InceptionDate,
    decimal? ExpiringPremium,
    string? Currency,
    IReadOnlyList<EvidenceCandidate>? Evidence);

internal sealed record EvidenceCandidate(
    string? Field,
    string? SourceText,
    int? PageNumber);
```

Nullable properties are appropriate here because the candidate reflects untrusted external output. The application model can be stricter after validation has established which values are present and supported.

The mapping step is then an intentional trust transition:

```csharp
internal static class RenewalInterpretationMappings
{
    public static RenewalInterpretation ToApplicationModel(
        this RenewalInterpretationCandidate candidate)
    {
        var premium = candidate.ExpiringPremium is not null &&
                      candidate.Currency is not null
            ? new Money(
                candidate.ExpiringPremium.Value,
                candidate.Currency)
            : null;

        var evidence = candidate.Evidence?
            .Where(x => x.Field is not null &&
                        x.SourceText is not null)
            .Select(x => new InterpretationEvidence(
                x.Field!,
                x.SourceText!,
                x.PageNumber))
            .ToArray() ?? [];

        return new RenewalInterpretation(
            candidate.InsuredName!,
            candidate.InceptionDate,
            premium,
            evidence,
            []);
    }
}
```

The null forgiving operator is safe only because this mapper is reached after validation. Keeping the candidate internal prevents another caller from bypassing that sequence and treating it as accepted data.

## Validation is part of the translation

An anti-corruption layer protects the application from foreign semantics as well as foreign types. For an LLM, that includes plausible answers unsupported by the input. Validation should use deterministic checks wherever the application has enough information. It can confirm required fields, supported currencies, permitted value ranges and the presence of evidence. It can also check that quoted evidence exists in the supplied content.

```csharp
public sealed class RenewalInterpretationValidator(
    ICurrencyReferenceData currencyReferenceData)
    : IRenewalInterpretationValidator
{
    public async Task<InterpretationValidation> ValidateAsync(
        RenewalInterpretationCandidate candidate,
        RenewalSubmission submission,
        CancellationToken stopToken)
    {
        if (string.IsNullOrWhiteSpace(candidate.InsuredName))
        {
            return InterpretationValidation.Rejected(
                "The insured name is missing.");
        }

        if (candidate.ExpiringPremium is <= 0)
        {
            return InterpretationValidation.Rejected(
                "The expiring premium must be greater than zero.");
        }

        if (candidate.Currency is not null &&
            !await currencyReferenceData.ExistsAsync(
                candidate.Currency,
                stopToken))
        {
            return InterpretationValidation.Rejected(
                "The extracted currency is not recognised.");
        }

        var evidence = candidate.Evidence ?? [];
        if (evidence.Count == 0)
        {
            return InterpretationValidation.Rejected(
                "The interpretation contains no supporting evidence.");
        }

        var containsUnsupportedEvidence = evidence.Any(x =>
            string.IsNullOrWhiteSpace(x.SourceText) ||
            !submission.Content.Contains(
                x.SourceText,
                StringComparison.OrdinalIgnoreCase));

        if (containsUnsupportedEvidence)
        {
            return InterpretationValidation.Rejected(
                "One or more evidence references cannot be found in the submission.");
        }

        return InterpretationValidation.Accepted();
    }
}

public sealed record InterpretationValidation(
    bool IsAccepted,
    string? Reason)
{
    public static InterpretationValidation Accepted() =>
        new(true, null);

    public static InterpretationValidation Rejected(
        string reason) =>
        new(false, reason);
}
```

This validation result is local to the boundary. It doesnt require the entire application to adopt a result type for every operation. The validator will vary by use case. A summariser may accept partial output with warnings. A pricing or compliance workflow may reject any field without direct evidence. The provider can be shared while each capability retains its own acceptance rules.

## Keep model routing out of business workflows

Model selection often starts as a configuration value read directly by an application service. That choice soon depends on cost, latency, data sensitivity, regional availability and the complexity of the request. Those concerns belong behind the application port. A routing decorator can select an implementation without exposing model names to the caller:

```csharp
public sealed class RoutedRenewalSubmissionInterpreter(
    IEnumerable<IRenewalInterpreterRoute> routes,
    IRenewalRoutePolicy routePolicy)
    : IRenewalSubmissionInterpreter
{
    public async Task<RenewalInterpretation> InterpretAsync(
        RenewalSubmission submission,
        CancellationToken stopToken)
    {
        var route = routePolicy.SelectRoute(submission, routes);

        return await route.InterpretAsync(
            submission,
            stopToken);
    }
}
```

The application still asks for a renewal interpretation. Infrastructure policy decides whether that requires a fast model, a more capable model or a deterministic implementation. Retries, fallbacks and [circuit breakers](https://fullstackcity.com/building-an-ai-circuit-breaker-in-net) can be composed around individual routes. Their telemetry can include provider and model identifiers internally while application logs continue to use the capability name and submission identifier. This separation becomes particularly valuable when a central AI gateway is introduced. The consuming application keeps its domain facing port. The adapter changes from calling a model provider to calling the gateway. Gateway request and response contracts remain external integration types and do not become domain types simply because the gateway is owned by the same organisation.

## Prevent tool calls from becoming domain commands

Agentic integrations create another route for provider concepts to leak. A model returns a tool name and arguments, then application code dispatches them directly to a command handler. This gives the model's representation too much authority over the application boundary. A tool call should first be translated into an application request and validated under normal authorisation and business rules. The tool name is an external protocol value. It should never be treated as proof that the requested action is valid.

```csharp
internal sealed class RenewalToolCallTranslator
{
    public ProposedRenewalAction Translate(
        ProviderToolCall toolCall) =>
        toolCall.Name switch
        {
            "request_missing_information" =>
                TranslateMissingInformationRequest(toolCall.Arguments),

            "refer_to_underwriter" =>
                TranslateUnderwriterReferral(toolCall.Arguments),

            _ => throw new UnsupportedAiToolException(toolCall.Name)
        };

    private static ProposedRenewalAction
        TranslateMissingInformationRequest(string arguments)
    {
        var candidate = JsonSerializer.Deserialize<
            MissingInformationCandidate>(arguments)
            ?? throw new InvalidAiToolArgumentsException();

        return new RequestMissingInformation(
            candidate.Fields ?? []);
    }

    private static ProposedRenewalAction
        TranslateUnderwriterReferral(string arguments)
    {
        var candidate = JsonSerializer.Deserialize<
            UnderwriterReferralCandidate>(arguments)
            ?? throw new InvalidAiToolArgumentsException();

        return new ReferToUnderwriter(
            candidate.Reason ?? "No reason supplied");
    }
}
```

The translated action is still only a proposal. The application decides whether the current user, workflow state and submission permit it. This keeps provider tool semantics away from command execution and preserves the same controls used by non-AI callers.

## Design failure contracts deliberately

Provider SDKs expose detailed failure information. HTTP status codes, safety categories, finish reasons and retry headers can all be useful inside infrastructure. Returning them directly forces application code to understand provider behaviour. The adapter should map those failures according to what the application can do next. `AiCapacityException` can indicate that a retry or alternate route is appropriate. `SubmissionContentRejectedException` can move the workflow to manual handling. `InterpretationRejectedException` can record that inference completed but the output failed application validation.

These names remain stable even if a new provider represents the same conditions differently. The mapping will never be perfect, especially when providers expose different safety and capacity semantics. That is precisely why the translation deserves its own explicit code. Avoid collapsing every response into success or failure too early. Operationally distinct outcomes should remain distinct long enough for retry, fallback and workflow policies to make a sensible decision.

## Test the application without a model

A clean application port makes most workflow tests independent of model SDKs and prompt details.

```csharp
public sealed class StubRenewalSubmissionInterpreter(
    RenewalInterpretation interpretation)
    : IRenewalSubmissionInterpreter
{
    public Task<RenewalInterpretation> InterpretAsync(
        RenewalSubmission submission,
        CancellationToken stopToken) =>
        Task.FromResult(interpretation);
}
```

Tests for the surrounding workflow can supply an accepted interpretation, a warning or an application level exception. They do not need to construct provider messages or mock a streaming chat response. The adapter receives a different test suite. Contract tests verify provider request construction and response parsing. Evaluation fixtures replay previously observed outputs through validation. Integration tests confirm that the selected model and schema can satisfy representative inputs. This division keeps model nondeterminism away from ordinary application tests without pretending that the integration can be validated entirely through mocks.

## Do not build a universal AI abstraction

An anti-corruption layer should protect a specific boundary. It does not need to predict every future AI use case. A generic interface with methods for chat, embeddings, images, audio, tools and agents recreates a provider SDK inside the application. Its types become vague because they must serve unrelated capabilities. Callers then cast metadata, pass dictionaries of options or depend on fields that only one implementation understands.

Go for narrow ports such as `IRenewalSubmissionInterpreter`, `IClaimsDocumentClassifier` or `IRiskNarrativeSummariser`. They can share lower-level infrastructure clients internally, but each exposes the language and acceptance rules of its use case. There may still be a provider neutral client beneath those adapters. That client can standardise authentication, telemetry, retries and raw structured completions. It belongs in infrastructure and should remain invisible to the business workflow. The abstraction earns its place when it reduces what the caller needs to know. If every model option remains available through a dictionary, the provider has simply been moved behind a thinner door.

## Version the boundary by behaviour

Changing a prompt can alter application behaviour even when the C# contract remains unchanged. The same is true for changing the model, response schema, retrieval process or validation rules. The adapter should emit an inference version that identifies the complete behaviour used to create an interpretation. This version can be stored with the output and included in telemetry without becoming an input to the business workflow.

When the response contract genuinely changes, normal compatibility rules apply. Adding optional evidence may be backward compatible. Changing the meaning of a premium field is a semantic contract change and deserves a new application type or versioned capability. Keeping the provider response separate from the application response makes these decisions visible. Otherwise, a provider SDK update can change the shape or meaning of data that has already escaped into controllers, messages and persistence models.

## Protect the application's centre

An LLM is an external system with probabilistic behaviour, evolving APIs and concepts designed around inference. It deserves the same architectural suspicion as any other influential integration. The anti-corruption layer gives the application a place to absorb that complexity. Provider messages become business requests. Structured candidates become validated application models. Tool calls become proposed actions. Technical failures become application-level outcomes. Prompts, schemas, routing and model identifiers remain on the infrastructure side of the boundary.

This does not make the LLM interchangeable in every practical sense. Different models produce different results, support different capabilities and require different tuning. It does prevent those differences from spreading through code that should only understand the business operation. The most useful AI abstraction is rarely `IAiService`. It is the capability the application needed before anyone decided an LLM would implement it.
