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.
// 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.
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.
// 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.
// 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.
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:
- Scaffold a new project:
dotnet new console -n AdapterPattern
- Enter the project folder:
cd AdapterPattern
- Modify Program.cs with the code below
- Execute:
dotnet run
// 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);
<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.