Anti-Corruption Layer in Domain-Driven Design

Anti-Corruption Layer in Domain-Driven Design

By

5 min read··

clean-architectureddddotnetsoftware-architecture

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.

The anti-corruption layer pattern 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

An ACL can be a class or module inside your application. It does not require a separately deployed service. In Clean Architecture, 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:

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:

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:

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:

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. 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, 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.

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.

  • Aggregate Design in DDD - Rules, Boundaries, and Consistency

    Aggregates are the most important tactical pattern in Domain-Driven Design. They define consistency boundaries, enforce invariants, and protect your domain model. Here are the rules and practical guidance for designing aggregates in .NET.

  • Aggregate Root in DDD: Rules and Implementation

    The Aggregate Root is the gatekeeper of consistency in Domain-Driven Design. Here are the rules for designing aggregate roots and implementing them in C#.

  • The Always-Valid Domain Model

    An always-valid domain model enforces business invariants at construction and on every state change. Private constructors, validated value objects, and guarded methods reduce repeated validation while keeping persistence and concurrency checks explicit.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.