You have designed a pristine Domain-Driven Design (DDD) model. You have ubiquitous language, bounded contexts, and aggregates that perfectly model the business intent. Then comes the requirement: "Sync this with the Mainframe," or "Fetch inventory levels from the 15-year-old SAP instance."
Suddenly, your clean domain entities are polluted with properties like KUNNR_Z01 or string-based status codes that only make sense if you have a CSV lookup table from 2005 on your desk.
This is the death of a domain model. When you allow external implementation details to dictate your internal domain structure, you fall into the Conformist pattern. You need an Anti-Corruption Layer (ACL).
The Root Cause: Semantic Coupling
The problem isn't just that legacy variable names are ugly. The problem is Semantic Coupling.
Legacy systems often flatten complex concepts into single database tables or cryptic XML structures to save space or satisfy ancient architectural constraints. Conversely, modern DDD aggregates utilize Value Objects, Enums, and rich behavior.
If you map a legacy DTO directly to your Domain Entity, you import the limitations of the legacy system into your logic.
- Leaky Abstractions: Your domain service now has to know that status
"X"means "Cancelled" and"P"means "Pending". - Fragility: If the ERP changes a column name (unlikely, but possible) or semantic meaning, your core business logic breaks.
- Anemic Domain: You stop using rich types (like
MonetaryAmountorSku) and revert to primitives (decimal,string) to match the legacy schema.
The Fix: Implementing a Strict ACL
We will implement an ACL using C# and .NET 8. We will create a strict boundary between a modern Inventory context and a legacy ERP system (simulated here as a SOAP-style XML structure).
The goal: The Domain Layer must define the interface it needs. The Infrastructure Layer must fulfill it, handling all translation and ugliness within a quarantined zone.
1. The Clean Domain (The Goal)
First, define what your domain actually needs. Do not look at the legacy database schema yet.
namespace ModernLogistics.Domain.Model;
// Value Object for strict logic
public record StockLevel(int Quantity, string UnitOfMeasure)
{
public static StockLevel Create(int quantity, string uom)
{
if (quantity < 0) throw new InvalidOperationException("Stock cannot be negative");
return new(quantity, uom);
}
}
public enum InventoryStatus
{
Available,
Reserved,
Discontinued,
Unknown
}
// The clean Domain Entity
public class ProductInventory
{
public Guid Id { get; }
public string Sku { get; }
public StockLevel Stock { get; private set; }
public InventoryStatus Status { get; private set; }
public ProductInventory(Guid id, string sku, StockLevel stock, InventoryStatus status)
{
Id = id;
Sku = sku;
Stock = stock;
Status = status;
}
}
// The Port (Interface) defined by the Domain
// The Domain doesn't care WHERE this comes from.
public interface IInventoryPort
{
Task<ProductInventory?> GetInventoryForSkuAsync(string sku, CancellationToken ct);
}
2. The Legacy Reality (The Mess)
In the Infrastructure layer, we model the external system exactly as it appears. This represents the "Corruption." We use XmlSerializer attributes here to represent a typical nasty legacy response.
namespace ModernLogistics.Infrastructure.LegacyErp.Dtos;
using System.Xml.Serialization;
// Represents a response from a legacy SOAP/XML endpoint
// Example: <Z_MAT_STOCK><MATNR>X-99</MATNR><MENGE>50.00</MENGE><MEINS>PCS</MEINS><Z_STAT>01</Z_STAT></Z_MAT_STOCK>
[XmlRoot("Z_MAT_STOCK")]
public class LegacyMaterialStockDto
{
[XmlElement("MATNR")]
public string? MaterialNumber { get; set; } // The Legacy SKU
[XmlElement("MENGE")]
public decimal Quantity { get; set; } // Legacy uses decimal for everything
[XmlElement("MEINS")]
public string? BaseUnit { get; set; }
[XmlElement("Z_STAT")]
public string? StatusCode { get; set; } // "01"=Active, "99"=Deleted
}
3. The Anti-Corruption Layer (The Translator)
The ACL is not just a mapper; it is a defense mechanism. It must handle:
- Data Type Conversion:
decimaltoint, Strings to Enums. - Semantic Translation: Mapping "99" to
Discontinued. - Error Shielding: Preventing bad data from crashing the domain.
namespace ModernLogistics.Infrastructure.LegacyErp.Acl;
using ModernLogistics.Domain.Model;
using ModernLogistics.Infrastructure.LegacyErp.Dtos;
public static class InventoryTranslator
{
public static ProductInventory Translate(LegacyMaterialStockDto dto)
{
// 1. Guard against corruption immediately
if (string.IsNullOrWhiteSpace(dto.MaterialNumber))
{
throw new InvalidOperationException("Legacy ERP returned item without Material Number.");
}
// 2. Encapsulate Legacy Rules (The "Corruption") here, not in the Domain
var status = dto.StatusCode switch
{
"01" => InventoryStatus.Available,
"02" => InventoryStatus.Reserved,
"99" => InventoryStatus.Discontinued,
_ => InventoryStatus.Unknown
};
// 3. Handle Type Mismatches Safeley
// Legacy system sends decimal 50.00 for integer stock.
int quantity = (int)Math.Floor(dto.Quantity);
// 4. Create Domain Value Objects
// If UOM is missing, we default or throw based on business rule
var uom = !string.IsNullOrWhiteSpace(dto.BaseUnit) ? dto.BaseUnit : "UNIT";
var stockLevel = StockLevel.Create(quantity, uom);
// 5. Return Clean Entity
// Note: In real scenarios, ID might be generated or mapped from a persistent store
return new ProductInventory(
Guid.NewGuid(),
dto.MaterialNumber,
stockLevel,
status
);
}
}
4. The Adapter Implementation
Finally, we implement the Domain Interface (IInventoryPort). This adapter sits in the Infrastructure layer and wires the Legacy Client to the Translator.
namespace ModernLogistics.Infrastructure.Adapters;
using ModernLogistics.Domain.Model;
using ModernLogistics.Infrastructure.LegacyErp.Acl;
using ModernLogistics.Infrastructure.LegacyErp.Dtos;
using System.Net.Http.Json; // Assuming we have a wrapper to fetch the XML/JSON
public class ErpInventoryAdapter : IInventoryPort
{
private readonly HttpClient _httpClient;
private readonly ILogger<ErpInventoryAdapter> _logger;
public ErpInventoryAdapter(HttpClient httpClient, ILogger<ErpInventoryAdapter> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<ProductInventory?> GetInventoryForSkuAsync(string sku, CancellationToken ct)
{
try
{
// 1. Call the Dirty System
// In reality, this might be a SOAP envelope construction
var response = await _httpClient.GetAsync($"/sap/stock?matnr={sku}", ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("ERP returned {Status} for SKU {Sku}", response.StatusCode, sku);
return null;
}
// deserialization logic omitted for brevity, assume we get the DTO
var legacyDto = await response.Content.ReadFromJsonAsync<LegacyMaterialStockDto>(cancellationToken: ct);
if (legacyDto == null) return null;
// 2. Invoke the ACL Translator
return InventoryTranslator.Translate(legacyDto);
}
catch (Exception ex)
{
// 3. Fail gracefully. Do not let infrastructure exceptions leak into the Domain.
_logger.LogError(ex, "Failed to fetch/map inventory from ERP for {Sku}", sku);
throw new InfrastructureException("Unable to retrieve inventory from external provider.", ex);
}
}
}
// Exception specific to layer, effectively wrapping the crash
public class InfrastructureException : Exception
{
public InfrastructureException(string message, Exception inner) : base(message, inner) { }
}
Why This Works
Decoupling
The ModernLogistics.Domain namespace has zero references to System.Xml.Serialization or the specific quirks of the legacy status codes. If the ERP team changes status "99" to "XX", you modify one line in the InventoryTranslator. The Domain remains untouched.
Testability
You can now unit test your Domain Logic (ProductInventory) without mocking an HTTP client. You can also test the InventoryTranslator in isolation by feeding it various nasty XML permutations and asserting it returns valid Domain Objects or throws expected errors.
Cognitive Load
Developers working on business rules (discount calculation, shipping logic) work with InventoryStatus.Reserved. They never have to wonder, "Wait, was '02' reserved or backordered?" The cognitive complexity of the legacy system is contained entirely within the ACL.
Conclusion
An Anti-Corruption Layer is not overhead; it is insurance. It costs code to write upfront, but it pays dividends by preventing the technical debt of a 20-year-old system from infecting your modern architecture.
When integrating legacy ERPs:
- Define the Ideal interface first.
- Model the Dirty data as DTOs.
- Write a Translator that handles the ugly logic.
- Hide it all behind a Port/Adapter.
Keep your domain clean. Let the ACL handle the mud.