Keep other systems' models out of yours

ยท 2 min read

When a third-party API's field names and status codes spread through your code, every change on their side becomes a change on yours. An anti-corruption layer stops it at the boundary.

You integrate with a CRM. Its API returns accounts with fields like Acct_Status__c, statuses such as "Prospect - Cold", and dates as strings in a format of its own. To get going quickly, you deserialize the response into a class that mirrors it and pass that class around.

Six months later, those names and codes are in your services, your database and your business rules. Then the CRM team renames a status, or you switch CRM vendors, and you're changing code everywhere.

Translate at the edge

An anti-corruption layer (a term from Domain-Driven Design) is a thin piece of code at the boundary that translates between an external system's model and yours. Nothing outside it knows the external model exists.

// The CRM's shape, internal to the integration
internal record CrmAccountDto(string Id, string Name, string Acct_Status__c, string? Created_Date);

// Your shape, used everywhere else
public record Customer(CustomerId Id, string Name, CustomerStatus Status, DateOnly? Since);

public enum CustomerStatus { Prospect, Active, Suspended, Closed }
public class CrmCustomerSource(CrmClient crm) : ICustomerSource
{
    public async Task<Customer?> GetAsync(CustomerId id, CancellationToken ct)
    {
        var dto = await crm.GetAccountAsync(id.Value, ct);
        return dto is null ? null : ToCustomer(dto);
    }

    internal static Customer ToCustomer(CrmAccountDto dto) => new(
        new CustomerId(dto.Id),
        dto.Name.Trim(),
        dto.Acct_Status__c switch
        {
            "Prospect - Cold" or "Prospect - Warm" => CustomerStatus.Prospect,
            "Customer" => CustomerStatus.Active,
            "On Hold" => CustomerStatus.Suspended,
            "Churned" => CustomerStatus.Closed,
            var unknown => throw new UnknownCrmStatusException(unknown)
        },
        DateOnly.TryParse(dto.Created_Date, out var date) ? date : null);
}

The rest of your code depends on ICustomerSource and Customer. It never sees Acct_Status__c.

What the layer is responsible for

  • Names and shapes. Map external fields to your own names and types.
  • Codes and statuses. Collapse their status values into yours, and decide explicitly what happens with values you don't recognize. Failing loudly is usually better than silently guessing.
  • Formats. Parse dates, numbers and currencies once, here.
  • Quirks. Trim whitespace, treat empty strings as missing, handle the field that's sometimes a string and sometimes a number.

Test the mapping

The translation is pure code, which makes it easy to test thoroughly. Keep a few real (anonymized) API responses as test fixtures and assert on the domain objects they produce. When the external API changes, these tests tell you exactly what broke.

When it's worth it

Not every integration needs a formal layer. If an external API is simple, stable and its model already matches yours, a small DTO and a mapping method may be all you need. The more the external model differs from yours, and the more likely it is to change or be replaced, the more the boundary pays off.

Takeaway

Don't let external systems' models leak into your code. Translate their data into your own types at the boundary, handle unknown values deliberately, and test the mapping with real responses. When they change, only the boundary changes.