# Anti-Corruption Layer in Domain-Driven Design

> An Anti-Corruption Layer prevents external systems from polluting your domain model. It translates between your clean domain language and the messy reality of legacy systems or third-party APIs.

Published: 2026-09-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/anti-corruption-layer-ddd

An **anti-corruption layer** translates an external system's data and behavior into the language of your own domain.
In a .NET application, define an application-facing interface and implement the adapter beside the external client.
Use it when the models differ enough that importing the external vocabulary would constrain your local design.

## What Does an Anti-Corruption Layer Protect?

A legacy ERP might describe an order with `ORD_NUM`, `ORD_TYP_CD`, and `STAT_CD`.
Your application uses an order number, delivery priority, and fulfillment state.
The translation belongs at the integration boundary, so an ERP status code never becomes a condition scattered through your [**domain model**](https://milanjovanovic.tech/blog/rich-vs-anemic-domain-model).

The [**anti-corruption layer pattern**](https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer) protects semantics, not only naming.
For example, an external system's "active order" might mean accepted, paid, or merely entered into its database.
The adapter must map the documented meaning rather than guess from the label.

![The anti-corruption layer sits between your domain model and an external system, translating in both directions so external types never reach the domain](https://milanjovanovic.tech/blogs/articles/anti-corruption-layer-ddd/acl-translation.png)

An ACL can be a class or module inside your application.
It does not require a separately deployed service.
In [**Clean Architecture**](https://milanjovanovic.tech/blog/clean-architecture-dotnet), the application-facing interface belongs in Application and the external client and translation belong in Infrastructure.

## Define the Local Contract

This example imports orders from a fictional ERP.
The ERP contract specifies that all totals are in USD and gives exact meanings to its type and status codes.
Define the local read contract in `IOrderImportService.cs`:

```csharp
public enum DeliveryPriority { Standard, Express, Rush }
public enum ImportedOrderStatus { Accepted, Cancelled, Shipped }

public sealed record ImportedOrder(
    string OrderNumber,
    string CustomerNumber,
    DeliveryPriority Priority,
    decimal TotalUsd,
    ImportedOrderStatus Status);

public interface IOrderImportService
{
    Task<ImportedOrder?> GetAsync(string orderNumber, CancellationToken ct);
}
```

`ImportedOrder` is a local application DTO.
It is not a reconstituted aggregate and does not bypass the domain's creation rules.
An import use case can decide whether to create a local order, update a projection, or flag a conflict.

## Implement the Translation

Keep the ERP shape in Infrastructure.
The following types and adapter belong in `LegacyErpOrderService.cs`:

```csharp
using System.Text.Json.Serialization;

public sealed record LegacyOrderRecord(
    [property: JsonRequired] string ORD_NUM,
    [property: JsonRequired] string CUST_ID,
    [property: JsonRequired] int ORD_TYP_CD,
    [property: JsonRequired] decimal TOTAL_AMT,
    [property: JsonRequired] string STAT_CD);

public interface ILegacyErpClient
{
    Task<LegacyOrderRecord?> GetAsync(string orderNumber, CancellationToken ct);
}

public sealed class LegacyErpOrderService(ILegacyErpClient client)
    : IOrderImportService
{
    public async Task<ImportedOrder?> GetAsync(
        string orderNumber, CancellationToken ct)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(orderNumber);
        var record = await client.GetAsync(orderNumber, ct);
        if (record is null)
            return null;

        if (record.ORD_NUM != orderNumber ||
            string.IsNullOrWhiteSpace(record.CUST_ID) || record.TOTAL_AMT < 0)
        {
            throw new InvalidOperationException("Invalid ERP order data.");
        }

        var priority = record.ORD_TYP_CD switch
        {
            1 => DeliveryPriority.Standard,
            2 => DeliveryPriority.Express,
            3 => DeliveryPriority.Rush,
            _ => throw new InvalidOperationException(
                $"Unsupported ERP order type: {record.ORD_TYP_CD}")
        };

        var status = record.STAT_CD switch
        {
            "A" => ImportedOrderStatus.Accepted,
            "C" => ImportedOrderStatus.Cancelled,
            "S" => ImportedOrderStatus.Shipped,
            _ => throw new InvalidOperationException(
                $"Unsupported ERP order status: {record.STAT_CD}")
        };

        return new ImportedOrder(record.ORD_NUM, record.CUST_ID,
            priority, record.TOTAL_AMT, status);
    }
}
```

An unknown status stops translation instead of silently becoming `Accepted`.
Required JSON fields distinguish a missing amount from an explicitly supplied zero.
The import worker can record that failure for investigation without changing the local order.
A timeout or malformed response must also remain distinguishable from an order that does not exist.

The currency assumption is explicit because it is part of this fictional contract.
A multi-currency integration needs a currency field and provider-specific amount conversion; multiplying or dividing every amount by 100 is not generally correct.

## Wire the External Client

For this example, the ERP exposes `GET /orders/{number}` returning the record above.
The HTTP client handles transport while the ACL handles meaning:

```csharp
using System.Net;
using System.Net.Http.Json;

public sealed class LegacyErpClient(HttpClient http) : ILegacyErpClient
{
    public async Task<LegacyOrderRecord?> GetAsync(
        string orderNumber, CancellationToken ct)
    {
        var path = $"orders/{Uri.EscapeDataString(orderNumber)}";
        using var response = await http.GetAsync(path, ct);
        if (response.StatusCode == HttpStatusCode.NotFound)
            return null;

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<LegacyOrderRecord>(ct)
            ?? throw new InvalidOperationException("The ERP returned an empty order.");
    }
}
```

Register the client and adapter in an ASP.NET Core application's `Program.cs`:

```csharp
var builder = WebApplication.CreateBuilder(args);
var erpUrl = builder.Configuration["Erp:BaseUrl"]
    ?? throw new InvalidOperationException("Erp:BaseUrl is required.");

builder.Services.AddHttpClient<ILegacyErpClient, LegacyErpClient>(client =>
{
    client.BaseAddress = new Uri(erpUrl.TrimEnd('/') + "/");
    client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddScoped<IOrderImportService, LegacyErpOrderService>();

var app = builder.Build();
app.Run();
```

The endpoint and its DTO are an illustrative contract, not an SDK for a real ERP.
Configure authentication and error handling for the actual upstream system.
Keep that work inside Infrastructure, where the **HTTP client** and adapter are registered.

## Translate Events at the Same Boundary

The same principle applies when the ERP pushes changes.
Authenticate and validate the incoming request before translating its payload into a local integration message or application command.
A provider webhook is not automatically a domain event that should be broadcast to every handler.

Preserve the external event ID as an idempotency key.
Atomically record that it was processed with the resulting local changes and any outgoing [**outbox messages**](https://milanjovanovic.tech/blog/outbox-pattern-dual-write-problem).
Otherwise a retried webhook can repeat the import or leave the local state and outgoing notifications out of sync.

Keep translation separate from business decisions.
The ACL can explain that an ERP order was cancelled; the local aggregate decides whether its own order can still be cancelled after shipment.

## When Is an ACL Worth the Cost?

An ACL is useful when an external model differs from your [**ubiquitous language**](https://milanjovanovic.tech/blog/ubiquitous-language-ddd), changes independently, or exposes conventions you do not want in local business rules.
That can happen with a legacy application, a third-party API, or another module in a [**modular monolith**](https://milanjovanovic.tech/blog/modular-monolith-architecture-dotnet).

The cost is mapping code, contract tests, and maintenance as the external model evolves.
Translation also cannot hide a change in business meaning that the local model must understand.

Skip an elaborate ACL when the contract already matches the local model and simple mapping is sufficient.
The architectural boundary should earn its maintenance cost.

## Summary

- **Define a local interface** using the application's own language.
- **Translate in Infrastructure**, beside the external client.
- **Validate semantics**, including status meanings, currencies, and missing data.
- **Keep business decisions in the domain** rather than hiding them inside mapping code.
- **Handle delivery failures and duplicates** when the integration processes events.

The useful boundary is the one that contains external assumptions and makes changes visible in one place.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### What is an anti-corruption layer in DDD?

An anti-corruption layer is a translation boundary between your domain model and an external system. It converts external data models, naming conventions, and status codes into your domain types so external concepts never leak into your domain.

### Where does the anti-corruption layer live in Clean Architecture?

The interface is defined in the Application layer using domain language, and the translation logic is implemented in the Infrastructure layer, next to the external system client.

### Is an anti-corruption layer the same as an adapter?

They are closely related. An adapter converts one interface to another, while an anti-corruption layer goes further: it translates an entire external model, including naming, types, and semantics, into your bounded context language.

### When can you skip an anti-corruption layer?

When the external model already aligns with your domain, the integration is trivial, or you control both sides and share the same ubiquitous language. In those cases the translation adds indirection without protection.

### Do modules in a modular monolith need anti-corruption layers?

Often, yes. Each module is a bounded context with its own model, so translating another module's API responses into local concepts keeps module boundaries intact.
