👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Anti-Corruption Layer Pattern in DDD - Protecting Your Domain from External Systems in .NET

Anti-Corruption Layer Pattern in DDD - Protecting Your Domain from External Systems in .NET

Author - Abdul Rahman (Bhai)

DDD

22 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

Every real-world application eventually connects to something it does not own: a payment processor, an email gateway, a shipping API, a legacy CRM. And every time, the same danger appears — the external system's terminology, data shapes, and error conditions bleeding into your domain.

The Anti-Corruption Layer (ACL) is the pattern that stops this. It is not a framework feature or a complicated abstraction. It is simply a boundary where translation and protection happen.

In this article you will learn what the ACL is, the five jobs it does, exactly where it lives in the architecture, how to build one in C# with a real email gateway example, when to use one, and the most common mistakes teams make when they get it wrong.

Why we gonna do?

What Goes Wrong Without an ACL

Consider integrating a third-party email provider. Without an ACL, domain code starts looking like this:


// Domain method polluted with external infrastructure concerns
public class License
{
    public void Activate(HardwareFingerprint fingerprint)
    {
        // ... business logic ...

        // BAD: domain importing and using a third-party SDK directly
        var client = new SendGridClient("API_KEY");
        var msg = new SendGridMessage();
        msg.SetFrom(new EmailAddress("noreply@app.com"));
        msg.AddTo(new EmailAddress(Owner.Email));
        msg.SetSubject("License Activated");
        msg.AddContent(MimeType.Text, $"Activated on {fingerprint.Value}");
        client.SendEmailAsync(msg).GetAwaiter().GetResult(); // blocking!
    }
}

This code has four serious problems:


Problem                          | Consequence
────────────────────────────────────────────────────────────────────────────────
Domain imports third-party SDK   | Domain must change when you change email provider
Business rule mixed with I/O     | Impossible to unit-test without sending real email
Blocking HTTP call inside domain | Silent timeouts kill throughput under load
External exception types leak in | Domain error handling fills with SDK exception types
────────────────────────────────────────────────────────────────────────────────

The Five Jobs of an Anti-Corruption Layer


Job                            | Description
──────────────────────────────────────────────────────────────────────────────────
Translate external → domain    | External concepts converted to your domain concepts
Validate incoming data         | Never blindly trust external payloads
Hide protocol details          | REST, JSON, headers, retries stay out of the domain
Protect domain purity          | Core logic stays clean, expressive, framework-free
Enable provider swapping       | Change the external provider without changing the domain
──────────────────────────────────────────────────────────────────────────────────

How we gonna do?

Step 1: Define the Port in the Domain

The domain defines an interface — a port — using its own language. No SDK types, no HTTP concepts, no provider names:


// File: LicenseContext/Domain/Ports/IEmailGateway.cs
namespace LicenseContext.Domain.Ports;

/// <summary>
/// Domain port: what the domain needs, expressed in domain language.
/// The domain does not know how emails are sent.
/// </summary>
public interface IEmailGateway
{
    Task SendLicenseActivatedAsync(
        EmailAddress recipient,
        LicenseId licenseId,
        HardwareFingerprint fingerprint,
        CancellationToken ct = default);

    Task SendLicenseExpiredAsync(
        EmailAddress recipient,
        LicenseId licenseId,
        CancellationToken ct = default);
}

Step 2: Create the Domain Value Objects Used by the Port


// File: LicenseContext/Domain/ValueObjects/EmailAddress.cs
namespace LicenseContext.Domain;

public sealed record EmailAddress
{
    public string Value { get; }

    public EmailAddress(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
            throw new ArgumentException("Invalid email address.", nameof(value));

        Value = value.Trim().ToLowerInvariant();
    }

    public override string ToString() => Value;
}

Step 3: Build the ACL Adapter in Infrastructure

The adapter lives in the infrastructure layer. It implements the domain port, imports the third-party SDK, and contains a translator that keeps all mapping logic in one place:


// File: LicenseContext/Infrastructure/Email/SendGridEmailAdapter.cs
using LicenseContext.Domain;
using LicenseContext.Domain.Ports;
using SendGrid;                 // only this file knows about SendGrid
// Note: SendGrid.Helpers.Mail is NOT imported here to avoid EmailAddress name clash.
// All SendGrid.Helpers.Mail types are used exclusively inside SendGridTranslator.

namespace LicenseContext.Infrastructure.Email;

public sealed class SendGridEmailAdapter : IEmailGateway
{
    private readonly ISendGridClient _client;
    private readonly SendGridOptions _options;

    public SendGridEmailAdapter(ISendGridClient client, SendGridOptions options)
    {
        _client = client;
        _options = options;
    }

    public async Task SendLicenseActivatedAsync(
        EmailAddress recipient,
        LicenseId licenseId,
        HardwareFingerprint fingerprint,
        CancellationToken ct = default)
    {
        var message = SendGridTranslator.BuildActivationMessage(
            _options.SenderEmail,
            recipient,
            licenseId,
            fingerprint);

        var response = await _client.SendEmailAsync(message, ct);

        if (!response.IsSuccessStatusCode)
        {
            // Translate SendGrid errors into domain exceptions - never leak SDK types
            var body = await response.Body.ReadAsStringAsync(ct);
            throw new EmailDeliveryException(
                $"Failed to send activation email to {recipient}: {response.StatusCode} - {body}");
        }
    }

    public async Task SendLicenseExpiredAsync(
        EmailAddress recipient,
        LicenseId licenseId,
        CancellationToken ct = default)
    {
        var message = SendGridTranslator.BuildExpiryMessage(
            _options.SenderEmail,
            recipient,
            licenseId);

        var response = await _client.SendEmailAsync(message, ct);

        if (!response.IsSuccessStatusCode)
        {
            var body = await response.Body.ReadAsStringAsync(ct);
            throw new EmailDeliveryException(
                $"Failed to send expiry email to {recipient}: {response.StatusCode} - {body}");
        }
    }
}

Step 4: Add the Translator

Separating the translator from the adapter keeps each class focused. The translator is where all field mapping, formatting, and provider-specific logic lives:


// File: LicenseContext/Infrastructure/Email/SendGridTranslator.cs
using SendGrid.Helpers.Mail;
// Alias SendGrid's EmailAddress to avoid clash with LicenseContext.Domain.EmailAddress.
using MailAddress = SendGrid.Helpers.Mail.EmailAddress;

namespace LicenseContext.Infrastructure.Email;

internal static class SendGridTranslator
{
    internal static SendGridMessage BuildActivationMessage(
        string senderEmail,
        LicenseContext.Domain.EmailAddress recipient,   // fully qualified domain type
        LicenseId licenseId,
        HardwareFingerprint fingerprint)
    {
        var msg = new SendGridMessage();
        msg.SetFrom(new MailAddress(senderEmail, "Licensing Team"));
        msg.AddTo(new MailAddress(recipient.Value));
        msg.SetSubject("Your license has been activated");
        msg.AddContent(
            MimeType.Html,
            $"<p>License <strong>{licenseId.Value}</strong> was activated " +
            $"on device <strong>{fingerprint.Value}</strong>.</p>");
        return msg;
    }

    internal static SendGridMessage BuildExpiryMessage(
        string senderEmail,
        LicenseContext.Domain.EmailAddress recipient,   // fully qualified domain type
        LicenseId licenseId)
    {
        var msg = new SendGridMessage();
        msg.SetFrom(new MailAddress(senderEmail, "Licensing Team"));
        msg.AddTo(new MailAddress(recipient.Value));
        msg.SetSubject("Your license has expired");
        msg.AddContent(
            MimeType.Html,
            $"<p>License <strong>{licenseId.Value}</strong> has expired. " +
            $"Please renew to continue using the software.</p>");
        return msg;
    }
}

Step 5: Wire It Up in Dependency Injection


// File: Web/Program.cs or infrastructure bootstrapper
builder.Services.Configure<SendGridOptions>(
    builder.Configuration.GetSection("SendGrid"));

builder.Services.AddSingleton<ISendGridClient>(sp =>
{
    var options = sp.GetRequiredService<IOptions<SendGridOptions>>().Value;
    return new SendGridClient(options.ApiKey);
});

// Domain depends on IEmailGateway (port), gets SendGridEmailAdapter (adapter)
builder.Services.AddScoped<IEmailGateway, SendGridEmailAdapter>();

The domain never registers or knows about SendGridEmailAdapter. It only depends on IEmailGateway. To switch to a different email provider tomorrow, you write a new adapter and change one DI registration.

Where Does the ACL Live in the Architecture?


┌─────────────────────────────────────────────────────┐
│  Presentation Layer (Controllers, Razor Pages)      │
├─────────────────────────────────────────────────────┤
│  Application Layer (Use Cases, Application Services)│
├─────────────────────────────────────────────────────┤
│  Domain Layer (Entities, Value Objects, Aggregates) │
│   → defines IEmailGateway (the port)                │
├─────────────────────────────────────────────────────┤
│  Infrastructure Layer  ← ACL lives here             │
│   → SendGridEmailAdapter  (the adapter/ACL)         │
│   → SendGridTranslator    (the translator)          │
│   → SqlLicenseRepository  (persistence adapter)     │
└─────────────────────────────────────────────────────┘

When to Use and When to Skip the ACL


Use ACL when...                              | Skip ACL when...
──────────────────────────────────────────────────────────────────────
External model is very different             | External protocol is a clean industry standard
This is a core domain (model purity matters) | This is a generic/supporting subdomain
You cannot influence the upstream team       | You own both sides and can improve the API
External API changes frequently              | Integration is short-lived (one-time migration)
──────────────────────────────────────────────────────────────────────

Common ACL Mistakes


Mistake                                     | Why It Hurts
──────────────────────────────────────────────────────────────────────────────────
Business rules inside the ACL               | Rules belong in the domain, not infrastructure
Domain classes importing external SDK types | Breaks boundaries, forces domain to change
Returning external types from domain ports  | External model leaks into domain
Throwing SDK exceptions into app code       | Callers handle external errors, not domain errors
Hiding ACL inside domain packages           | ACL belongs in infrastructure
──────────────────────────────────────────────────────────────────────────────────

Summary

In this article, you learned how the Anti-Corruption Layer protects your domain from external system concepts:

  • An Anti-Corruption Layer is a boundary in the infrastructure layer that translates and protects — nothing more, nothing less.
  • The domain defines a port (interface) using domain language. The ACL provides the adapter (implementation) using external SDK types.
  • A translator inside the adapter handles all field mapping, formatting, and protocol details.
  • External exceptions are caught and re-thrown as domain exceptions.
  • Swapping providers requires only a new adapter and one DI registration change — zero domain code changes.
  • The ACL is not always necessary — skip it for generic subdomains, clean standards, or short-lived integrations.

With ports, adapters, and the ACL understood, it is time to zoom out and look at the overall architecture that holds it all together: the Onion Architecture.

👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • DDD
  • Domain-Driven Design
  • DDD
  • Anti-Corruption Layer
  • ACL
  • External Integration
  • Ports and Adapters
  • Domain Protection
  • .NET
  • C#
  • Clean Architecture