C# Adapter Pattern: Wrapping Legacy Interfaces

This article explores the Adapter design pattern and demonstrates how to effectively wrap incompatible legacy interfaces in C#. You will learn the core architecture of the pattern, understand the benefits of decoupling modern business logic from aging systems, and review a direct, real-world code implementation that translates modern service calls into legacy operations.


Understanding the Adapter Pattern

The Adapter pattern is a structural design pattern that allows objects with incompatible interfaces to collaborate. It functions like a physical power adapter: it translates the interface of one service or class into another interface that clients expect.

In enterprise software development, systems frequently undergo modernization while relying on stable, battle-tested legacy components. Directly altering legacy code introduces risk and often breaks existing workflows. The Adapter pattern solves this problem by creating an intermediary class—the adapter—that implements the modern interface while holding an instance of the legacy object. The adapter receives calls from the new system, transforms data structures and parameters as necessary, and forwards the transformed request to the legacy class.

Key Components

The pattern consists of four main elements:

  1. Target Interface: The modern interface required by the client application.
  2. Client: The new application code that interacts solely with the Target Interface.
  3. Adaptee: The existing legacy component with an incompatible API.
  4. Adapter: The bridge class that implements the Target Interface and wraps the Adaptee.

Implementation Example in C

Consider a modern payment service interface designed for dependency injection and standard data objects:

// Modern domain request model
public record PaymentRequest(string CustomerId, decimal Amount, string Currency);

// Target Interface expected by the modern application
public interface IPaymentService
{
    Task<bool> ProcessPaymentAsync(PaymentRequest request);
}

Next, consider an existing legacy billing class that cannot be modified directly. It uses primitive types, synchronous execution, and completely different method signatures:

// Adaptee: Incompatible legacy component
public class LegacyBillingSystem
{
    public int ExecuteTransaction(string accountCode, double totalCharge)
    {
        // Legacy system logic returns an integer status code (0 for success)
        Console.WriteLine($"Legacy processing: {accountCode} for ${totalCharge}");
        return 0; 
    }
}

To bridge the two systems without altering LegacyBillingSystem or polluting modern application logic, create the adapter:

// Adapter: Implements the modern interface by wrapping the legacy component
public class LegacyBillingAdapter : IPaymentService
{
    private readonly LegacyBillingSystem _legacySystem;

    public LegacyBillingAdapter(LegacyBillingSystem legacySystem)
    {
        _legacySystem = legacySystem ?? throw new ArgumentNullException(nameof(legacySystem));
    }

    public Task<bool> ProcessPaymentAsync(PaymentRequest request)
    {
        // Map modern domain structures to legacy parameters
        string accountCode = request.CustomerId;
        double chargeAmount = (double)request.Amount;

        // Delegate execution to the legacy class
        int statusCode = _legacySystem.ExecuteTransaction(accountCode, chargeAmount);

        // Convert the legacy response to modern expectations
        bool isSuccess = statusCode == 0;
        return Task.FromResult(isSuccess);
    }
}

Consuming the Adapter

The client code depends only on IPaymentService, maintaining strict separation of concerns:

public class OrderProcessor
{
    private readonly IPaymentService _paymentService;

    public OrderProcessor(IPaymentService paymentService)
    {
        _paymentService = paymentService;
    }

    public async Task CheckoutAsync(string customerId, decimal amount)
    {
        var request = new PaymentRequest(customerId, amount, "USD");
        bool result = await _paymentService.ProcessPaymentAsync(request);

        if (result)
        {
            Console.WriteLine("Order processed successfully.");
        }
    }
}

You can wire this up via standard .NET dependency injection:

// Register the legacy system and its adapter
services.AddSingleton<LegacyBillingSystem>();
services.AddTransient<IPaymentService, LegacyBillingAdapter>();

Key Benefits

  • Adherence to Open/Closed Principle: New interfaces can be supported without modifying the underlying legacy code.
  • Separation of Concerns: Data translation, error mapping, and impedance mismatches are isolated inside the adapter, keeping client logic clean.
  • Testability: Modern code remains testable through interface mocking, regardless of how difficult the legacy code is to test directly.