Adapter in C#: Make Old APIs Fit New Shapes

Bridging Incompatible Interfaces

If you've ever needed to use a third-party library that doesn't match your application's interface structure, you've hit the exact problem the Adapter pattern solves. Maybe you have legacy code expecting one method signature, but the new library provides a completely different API. Manually rewriting either side isn't practical.

The Adapter pattern wraps an incompatible class to make it work with your existing code. It acts like a translator, converting the interface you have into the interface you need. This lets you integrate disparate systems without modifying their source code, keeping changes localized to a single adapter class.

You'll learn how to implement both object and class adapters, when each approach fits best, and how to integrate adapters with modern dependency injection. By the end, you'll know how to connect incompatible interfaces elegantly without compromising your architecture.

The Interface Compatibility Problem

Consider a payment processing system that expects all payment providers to implement an IPaymentProcessor interface. You want to integrate a third-party payment library, but it uses completely different method names and parameters. You can't change the library's code, and refactoring your entire application to match the library's API would break existing integrations.

The Adapter pattern solves this by creating a wrapper class that implements your expected interface and translates calls to the third-party library's actual methods. Your application code continues using IPaymentProcessor without knowing it's talking to adapted code.

PaymentInterfaces.cs
// Your application's expected interface
public interface IPaymentProcessor
{
    bool ProcessPayment(decimal amount, string cardNumber);
    string GetTransactionId();
}

// Third-party library you want to integrate
public class StripePaymentService
{
    public string ChargeCard(string cardToken, int amountInCents)
    {
        Console.WriteLine($"Stripe: Charging ${amountInCents / 100.0:F2}");
        return $"stripe_{Guid.NewGuid():N}";
    }
}

// Without an adapter, you can't use StripePaymentService where IPaymentProcessor is expected

The StripePaymentService uses different method names, parameter types, and return values than IPaymentProcessor expects. An adapter bridges this gap without modifying either interface.

Implementing an Object Adapter

An object adapter uses composition: it holds an instance of the adaptee (the class being adapted) and implements the target interface. This is the most common approach in C# and works even when the adaptee is a sealed class or from a third-party library.

StripeAdapter.cs
public class StripePaymentAdapter : IPaymentProcessor
{
    private readonly StripePaymentService _stripeService;
    private string _lastTransactionId;

    public StripePaymentAdapter(StripePaymentService stripeService)
    {
        _stripeService = stripeService;
    }

    public bool ProcessPayment(decimal amount, string cardNumber)
    {
        try
        {
            // Convert parameters to Stripe's expected format
            int amountInCents = (int)(amount * 100);
            string cardToken = GenerateCardToken(cardNumber);

            // Call Stripe's actual method
            _lastTransactionId = _stripeService.ChargeCard(cardToken, amountInCents);

            return !string.IsNullOrEmpty(_lastTransactionId);
        }
        catch
        {
            return false;
        }
    }

    public string GetTransactionId()
    {
        return _lastTransactionId ?? "No transaction";
    }

    private string GenerateCardToken(string cardNumber)
    {
        // Simulate tokenization
        return $"tok_{cardNumber[^4..]}";
    }
}

// Usage
var stripeService = new StripePaymentService();
IPaymentProcessor processor = new StripePaymentAdapter(stripeService);

bool success = processor.ProcessPayment(49.99m, "1234567890123456");
Console.WriteLine($"Payment successful: {success}");
Console.WriteLine($"Transaction ID: {processor.GetTransactionId()}");

Output:

Stripe: Charging $49.99
Payment successful: True
Transaction ID: stripe_a1b2c3d4e5f6...

The adapter converts decimal dollars to integer cents, generates a card token from the card number, and stores the transaction ID so GetTransactionId can return it. Client code only sees IPaymentProcessor and never interacts with StripePaymentService directly.

Creating a Two-Way Adapter

Sometimes you need bidirectional compatibility. A two-way adapter implements both interfaces, allowing it to be used wherever either interface is expected. This is useful when integrating two subsystems that need to communicate but have incompatible interfaces.

TwoWayAdapter.cs
// Legacy logging interface
public interface ILegacyLogger
{
    void WriteLog(string message, int severity);
}

// Modern logging interface
public interface IModernLogger
{
    void Log(LogLevel level, string message);
}

public enum LogLevel { Debug, Info, Warning, Error }

// Two-way adapter
public class LoggerAdapter : ILegacyLogger, IModernLogger
{
    // ILegacyLogger implementation
    public void WriteLog(string message, int severity)
    {
        var level = severity switch
        {
            0 => LogLevel.Debug,
            1 => LogLevel.Info,
            2 => LogLevel.Warning,
            _ => LogLevel.Error
        };
        Log(level, message);
    }

    // IModernLogger implementation
    public void Log(LogLevel level, string message)
    {
        Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{level}] {message}");
    }
}

// Can be used as either interface
ILegacyLogger legacyLogger = new LoggerAdapter();
legacyLogger.WriteLog("Legacy system message", 2);

IModernLogger modernLogger = new LoggerAdapter();
modernLogger.Log(LogLevel.Info, "Modern system message");

Output:

[14:30:22] [Warning] Legacy system message
[14:30:22] [Info] Modern system message

The adapter translates between integer severity levels and enum LogLevel values, making it usable with both old and new code. This pattern helps during gradual migrations where you can't update everything at once.

Adapting External APIs

A practical scenario is integrating different cloud storage providers (Azure, AWS, GCP) behind a unified interface. Each provider has its own SDK with different method names and authentication patterns. Adapters let your application code remain provider-agnostic.

StorageAdapters.cs
// Unified storage interface
public interface ICloudStorage
{
    Task UploadAsync(string fileName, byte[] data);
    Task DownloadAsync(string fileName);
}

// Simulated Azure SDK
public class AzureBlobClient
{
    public async Task PutBlobAsync(string name, byte[] content)
    {
        await Task.Delay(50);
        Console.WriteLine($"Azure: Uploaded {name} ({content.Length} bytes)");
    }

    public async Task GetBlobAsync(string name)
    {
        await Task.Delay(50);
        return new byte[] { 1, 2, 3 };
    }
}

// Simulated AWS SDK
public class S3Client
{
    public async Task PutObjectAsync(string key, byte[] body)
    {
        await Task.Delay(50);
        Console.WriteLine($"AWS: Uploaded {key} ({body.Length} bytes)");
    }

    public async Task GetObjectAsync(string key)
    {
        await Task.Delay(50);
        return new byte[] { 4, 5, 6 };
    }
}

// Azure adapter
public class AzureStorageAdapter : ICloudStorage
{
    private readonly AzureBlobClient _client = new();

    public Task UploadAsync(string fileName, byte[] data)
        => _client.PutBlobAsync(fileName, data);

    public Task DownloadAsync(string fileName)
        => _client.GetBlobAsync(fileName);
}

// AWS adapter
public class AwsStorageAdapter : ICloudStorage
{
    private readonly S3Client _client = new();

    public Task UploadAsync(string fileName, byte[] data)
        => _client.PutObjectAsync(fileName, data);

    public Task DownloadAsync(string fileName)
        => _client.GetObjectAsync(fileName);
}

// Application code doesn't care about the provider
async Task StoreDocument(ICloudStorage storage, string name, byte[] content)
{
    await storage.UploadAsync(name, content);
    Console.WriteLine("Document stored successfully");
}

await StoreDocument(new AzureStorageAdapter(), "report.pdf", new byte[1024]);
await StoreDocument(new AwsStorageAdapter(), "report.pdf", new byte[1024]);

Output:

Azure: Uploaded report.pdf (1024 bytes)
Document stored successfully
AWS: Uploaded report.pdf (1024 bytes)
Document stored successfully

Switching cloud providers only requires changing which adapter you inject. Your business logic stays unchanged, and you can even support multiple providers simultaneously by using different adapters in different parts of your application.

Adapters with Dependency Injection

Modern .NET applications use DI to manage dependencies. Adapters fit perfectly into this pattern: register the adapter in your DI container and inject the target interface. Configuration determines which concrete adapter gets created.

DIConfiguration.cs
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

// Register based on configuration
string cloudProvider = Environment.GetEnvironmentVariable("CLOUD_PROVIDER") ?? "azure";

if (cloudProvider.Equals("azure", StringComparison.OrdinalIgnoreCase))
{
    services.AddSingleton();
}
else if (cloudProvider.Equals("aws", StringComparison.OrdinalIgnoreCase))
{
    services.AddSingleton();
}

// Services depend only on ICloudStorage
services.AddTransient();

var provider = services.BuildServiceProvider();

public class DocumentService
{
    private readonly ICloudStorage _storage;

    public DocumentService(ICloudStorage storage)
    {
        _storage = storage;
    }

    public async Task SaveDocument(string name, byte[] content)
    {
        await _storage.UploadAsync(name, content);
        Console.WriteLine($"Saved {name} using {_storage.GetType().Name}");
    }
}

var docService = provider.GetRequiredService();
await docService.SaveDocument("invoice.pdf", new byte[512]);

The DocumentService knows nothing about Azure or AWS. It only depends on ICloudStorage. Environment variables or configuration files control which adapter gets injected, making the application deployable to different clouds without code changes.

Try It Yourself

Here's a complete example showing how to adapt a temperature API that returns Fahrenheit values to an interface expecting Celsius. This demonstrates unit conversion through an adapter.

Steps:

  1. Scaffold a new project: dotnet new console -n AdapterPattern
  2. Enter the project folder: cd AdapterPattern
  3. Modify Program.cs with the code below
  4. Execute: dotnet run
Demo.cs
// Target interface (expects Celsius)
interface ITemperatureSensor
{
    double GetTemperatureCelsius();
}

// Adaptee (returns Fahrenheit)
class FahrenheitSensor
{
    public double ReadTemperature()
    {
        return 77.0; // 77°F
    }
}

// Adapter
class TemperatureAdapter : ITemperatureSensor
{
    private readonly FahrenheitSensor _sensor;

    public TemperatureAdapter(FahrenheitSensor sensor)
    {
        _sensor = sensor;
    }

    public double GetTemperatureCelsius()
    {
        double fahrenheit = _sensor.ReadTemperature();
        double celsius = (fahrenheit - 32) * 5 / 9;
        return Math.Round(celsius, 2);
    }
}

// Client code
void DisplayTemperature(ITemperatureSensor sensor)
{
    double temp = sensor.GetTemperatureCelsius();
    Console.WriteLine($"Temperature: {temp}°C");

    if (temp > 30)
        Console.WriteLine("Status: Hot");
    else if (temp > 20)
        Console.WriteLine("Status: Comfortable");
    else
        Console.WriteLine("Status: Cold");
}

var fahrenheitSensor = new FahrenheitSensor();
ITemperatureSensor celsiusAdapter = new TemperatureAdapter(fahrenheitSensor);

DisplayTemperature(celsiusAdapter);
AdapterPattern.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

What you'll see:

Temperature: 25°C
Status: Comfortable

The adapter converts 77°F to 25°C behind the scenes. The DisplayTemperature method works with any ITemperatureSensor, whether it's a native Celsius sensor or an adapted Fahrenheit sensor.

Design Trade-offs

Object adapter vs class adapter: Use object adapters (composition) when the adaptee is sealed or from a third-party library. Use class adapters (inheritance) only when you control both classes and need to override adaptee behavior. C# doesn't support multiple inheritance, so class adapters are less common.

One adapter vs multiple: Create a single adapter for each adaptee-target pair. Don't try to make one adapter support multiple target interfaces unless they're closely related. Multiple focused adapters are easier to test and maintain than one complex adapter.

Adapter vs facade: Adapters change an interface to match an existing interface. Facades simplify a complex subsystem with a new interface. If you're wrapping a single incompatible class, use Adapter. If you're simplifying multiple related classes, use Facade.

Performance considerations: Adapters add a layer of indirection with minimal overhead. However, if performance is critical and you're adapting in a hot path, consider caching adapted results or using value types to avoid allocations. Profile before optimizing.

Reader Questions

When should I use Adapter instead of modifying the original class?

Use Adapter when you can't modify the source (third-party libraries, sealed classes) or when changing the original would break existing code. Adapters keep compatibility layers isolated and prevent ripple effects across your codebase.

Can I adapt multiple incompatible classes to one interface?

Yes, create one adapter per incompatible class. Each adapter implements the target interface and wraps its specific adaptee. This gives you multiple implementations of the target interface, making them interchangeable.

How do I test code that uses adapters?

Mock or stub the target interface in your tests. The adapter itself should have minimal logic beyond translation. Test adapters separately by verifying they correctly convert parameters and return values when calling the adaptee.

What's the difference between Adapter and Wrapper?

Adapter is a specific pattern for interface compatibility. Wrapper is a general term for any class that wraps another. All adapters are wrappers, but not all wrappers are adapters. Use Adapter specifically when solving interface mismatches.

Should adapters perform business logic?

No, keep adapters focused on translation. They should convert data formats, parameter orders, and method names but shouldn't add business rules. If you need logic beyond translation, put it in a separate service layer.

Back to Articles