BrandGhost
Adapter Pattern Real-World Example in C#: Complete Implementation

Adapter Pattern Real-World Example in C#: Complete Implementation

Adapter Pattern Real-World Example in C#: Complete Implementation

Most adapter pattern tutorials show you how to convert a square peg into a round hole. That's great for understanding the mechanics, but it won't help you the next time you're staring at two payment APIs with completely different interfaces and a deadline breathing down your neck. This article builds a complete adapter pattern real-world example in C# -- a payment processing system that unifies Stripe-like and PayPal-like APIs behind a single interface your application code can depend on.

By the end, you'll have a compilable implementation covering the full evolution: from the problem that motivates the adapter pattern, through interface design, adapter implementation, unit testing, and dependency injection registration. Every piece connects, and every design decision gets explained. If you want to understand how the adapter pattern fits into real production architectures alongside other design patterns, this is the article that shows you.

The Problem: Incompatible Payment Provider APIs

You're building a checkout service. The business already uses a Stripe-like payment provider, and now they want PayPal support. Later, they'll add a bank transfer provider. Each vendor ships its own SDK with its own API surface, naming conventions, and response formats.

Here's what happens without the adapter pattern:

public class CheckoutService
{
    private readonly StripeApi _stripe;
    private readonly PayPalLegacyApi _paypal;

    public async Task<string> ChargeCustomer(
        string provider,
        decimal amount,
        string currency,
        string customerId)
    {
        if (provider == "stripe")
        {
            var result = await _stripe.CreateChargeAsync(
                new StripeChargeRequest
                {
                    AmountInCents = (long)(amount * 100),
                    CurrencyCode = currency,
                    CustomerId = customerId
                });
            return result.ChargeId;
        }
        else if (provider == "paypal")
        {
            var orderId = _paypal.BeginTransaction(
                amount.ToString("F2"),
                currency.ToUpper());
            _paypal.SetPayer(orderId, customerId);
            var capture = _paypal.ExecuteTransaction(
                orderId);
            return capture.TransactionId;
        }

        throw new ArgumentException(
            $"Unknown provider: {provider}");
    }
}

Every provider added means another else if branch. The checkout service knows intimate details about each SDK -- Stripe works in cents, PayPal uses a multi-step flow, and each has different property names for the same concept. Testing requires mocking every vendor's types. Swapping providers means surgery across the entire codebase.

The adapter pattern eliminates this by placing each vendor's SDK behind a common interface. Your application code depends on the interface. Each adapter translates between your interface and the vendor's API. Adding a new provider means writing one new adapter class -- nothing else changes.

Defining the Third-Party APIs

Before we build adapters, let's define the "legacy" APIs we need to adapt. In a real project, these would be vendor SDKs you can't modify. Here, we'll create two classes that simulate Stripe and PayPal with intentionally different interfaces:

public sealed class StripeApi
{
    public Task<StripeChargeResult> CreateChargeAsync(
        StripeChargeRequest request)
    {
        return Task.FromResult(new StripeChargeResult
        {
            ChargeId = $"ch_{Guid.NewGuid():N}",
            Status = "succeeded",
            AmountCaptured = request.AmountInCents
        });
    }

    public Task<StripeRefundResult> CreateRefundAsync(
        string chargeId,
        long amountInCents)
    {
        return Task.FromResult(new StripeRefundResult
        {
            RefundId = $"re_{Guid.NewGuid():N}",
            Status = "succeeded"
        });
    }
}

public record StripeChargeRequest
{
    public long AmountInCents { get; init; }
    public string CurrencyCode { get; init; } = "usd";
    public string CustomerId { get; init; } = "";
}

public record StripeChargeResult
{
    public string ChargeId { get; init; } = "";
    public string Status { get; init; } = "";
    public long AmountCaptured { get; init; }
}

public record StripeRefundResult
{
    public string RefundId { get; init; } = "";
    public string Status { get; init; } = "";
}

Stripe's API is relatively straightforward -- amounts in cents, async methods, and descriptive result objects. Now look at the PayPal-like legacy API:

public sealed class PayPalLegacyApi
{
    private readonly Dictionary<string, decimal> _orders
        = new();

    public string BeginTransaction(
        string amount,
        string currencyCode)
    {
        var orderId = $"PAY-{Guid.NewGuid():N}";
        _orders[orderId] = decimal.Parse(amount);
        return orderId;
    }

    public void SetPayer(
        string orderId,
        string payerId)
    {
        // Associates payer with the order
    }

    public PayPalCaptureResult ExecuteTransaction(
        string orderId)
    {
        _orders.Remove(orderId);
        return new PayPalCaptureResult
        {
            TransactionId = $"TXN-{Guid.NewGuid():N}",
            State = "completed"
        };
    }

    public PayPalRefundResult RefundTransaction(
        string transactionId,
        string amount)
    {
        return new PayPalRefundResult
        {
            RefundId = $"RFD-{Guid.NewGuid():N}",
            State = "completed"
        };
    }
}

public record PayPalCaptureResult
{
    public string TransactionId { get; init; } = "";
    public string State { get; init; } = "";
}

public record PayPalRefundResult
{
    public string RefundId { get; init; } = "";
    public string State { get; init; } = "";
}

Notice the differences. PayPal uses synchronous methods, string-based amounts, a multi-step transaction flow, and "State" instead of "Status." These are exactly the kind of incompatibilities the adapter pattern exists to handle.

Designing the Target Interface

The target interface is the contract your application code depends on. It defines what a payment processor looks like from your system's perspective, regardless of which vendor sits behind it. This is where the adapter pattern starts to take shape -- every adapter will implement this single interface:

public interface IPaymentProcessor
{
    string ProviderName { get; }

    Task<PaymentResult> ChargeAsync(
        PaymentRequest request);

    Task<RefundResult> RefundAsync(
        string transactionId,
        decimal amount);
}

public sealed record PaymentRequest(
    decimal Amount,
    string Currency,
    string CustomerId);

public sealed record PaymentResult(
    string TransactionId,
    bool IsSuccessful,
    string ProviderReference);

public sealed record RefundResult(
    string RefundId,
    bool IsSuccessful);

A few things worth calling out here. The interface uses decimal for money -- not cents, not strings. It uses your domain's terminology, not any vendor's. The result types are simple, immutable records that capture just what your system needs. If you're interested in how interface-driven design connects to broader architectural principles, take a look at inversion of control and how it shapes the way you think about dependencies.

Building the Stripe Adapter

The first adapter translates between IPaymentProcessor and the Stripe API. The adapter pattern's job is pure translation -- it doesn't add behavior, it doesn't cache, it doesn't retry. It maps your domain types to the vendor's types and back:

public sealed class StripePaymentAdapter : IPaymentProcessor
{
    private readonly StripeApi _stripeApi;

    public StripePaymentAdapter(StripeApi stripeApi)
    {
        _stripeApi = stripeApi;
    }

    public string ProviderName => "Stripe";

    public async Task<PaymentResult> ChargeAsync(
        PaymentRequest request)
    {
        var stripeRequest = new StripeChargeRequest
        {
            AmountInCents = (long)(request.Amount * 100),
            CurrencyCode = request.Currency,
            CustomerId = request.CustomerId
        };

        var result = await _stripeApi
            .CreateChargeAsync(stripeRequest);

        return new PaymentResult(
            TransactionId: result.ChargeId,
            IsSuccessful: result.Status == "succeeded",
            ProviderReference: result.ChargeId);
    }

    public async Task<RefundResult> RefundAsync(
        string transactionId,
        decimal amount)
    {
        var result = await _stripeApi
            .CreateRefundAsync(
                transactionId,
                (long)(amount * 100));

        return new RefundResult(
            RefundId: result.RefundId,
            IsSuccessful: result.Status == "succeeded");
    }
}

The adapter converts dollars to cents for Stripe, maps Currency to CurrencyCode, and translates the status string into a boolean your system can use directly. The calling code never sees StripeChargeRequest or StripeChargeResult -- those types stay behind the adapter boundary.

Building the PayPal Adapter

The PayPal adapter is more interesting because the legacy API has a multi-step flow. The adapter collapses that into a single ChargeAsync call:

public sealed class PayPalPaymentAdapter : IPaymentProcessor
{
    private readonly PayPalLegacyApi _paypalApi;

    public PayPalPaymentAdapter(
        PayPalLegacyApi paypalApi)
    {
        _paypalApi = paypalApi;
    }

    public string ProviderName => "PayPal";

    public Task<PaymentResult> ChargeAsync(
        PaymentRequest request)
    {
        var orderId = _paypalApi.BeginTransaction(
            request.Amount.ToString("F2"),
            request.Currency.ToUpperInvariant());

        _paypalApi.SetPayer(
            orderId,
            request.CustomerId);

        var capture = _paypalApi.ExecuteTransaction(
            orderId);

        return Task.FromResult(new PaymentResult(
            TransactionId: capture.TransactionId,
            IsSuccessful: capture.State == "completed",
            ProviderReference: orderId));
    }

    public Task<RefundResult> RefundAsync(
        string transactionId,
        decimal amount)
    {
        var result = _paypalApi.RefundTransaction(
            transactionId,
            amount.ToString("F2"));

        return Task.FromResult(new RefundResult(
            RefundId: result.RefundId,
            IsSuccessful: result.State == "completed"));
    }
}

This adapter does more translation work than the Stripe one. It converts decimal amounts to formatted strings, orchestrates the begin-set-execute flow into a single method, maps "State" to the boolean IsSuccessful, and wraps synchronous calls in Task.FromResult to match the async interface. That's the beauty of the adapter pattern -- each adapter handles whatever complexity its vendor demands while presenting the same clean interface to the rest of your system.

Creating a Payment Processor Factory

With multiple adapters in play, you need a way to select the right one at runtime. A factory handles this cleanly. You could use an enum or string-based key, but for this adapter pattern example, we'll use an enum-keyed approach that integrates well with dependency injection:

public enum PaymentProvider
{
    Stripe,
    PayPal
}

public interface IPaymentProcessorFactory
{
    IPaymentProcessor Create(PaymentProvider provider);
}

public sealed class PaymentProcessorFactory
    : IPaymentProcessorFactory
{
    private readonly IReadOnlyDictionary<
        PaymentProvider,
        IPaymentProcessor> _processors;

    public PaymentProcessorFactory(
        IEnumerable<IPaymentProcessor> processors)
    {
        _processors = processors.ToDictionary(
            p => Enum.Parse<PaymentProvider>(
                p.ProviderName),
            p => p);
    }

    public IPaymentProcessor Create(
        PaymentProvider provider)
    {
        if (_processors.TryGetValue(
            provider,
            out var processor))
        {
            return processor;
        }

        throw new ArgumentException(
            $"No adapter registered for {provider}");
    }
}

The factory takes all registered IPaymentProcessor implementations via constructor injection, indexes them by their ProviderName, and hands back the right one on demand. Adding a new payment provider means registering a new adapter -- the factory picks it up automatically. This aligns nicely with plugin-style architectures where new capabilities are discovered rather than hardcoded.

Using the Adapted Payment Processors

With adapters and factory in place, the checkout service becomes clean and provider-agnostic:

public sealed class CheckoutService
{
    private readonly IPaymentProcessorFactory _factory;

    public CheckoutService(
        IPaymentProcessorFactory factory)
    {
        _factory = factory;
    }

    public async Task<PaymentResult> ProcessPayment(
        PaymentProvider provider,
        decimal amount,
        string currency,
        string customerId)
    {
        var processor = _factory.Create(provider);

        var request = new PaymentRequest(
            Amount: amount,
            Currency: currency,
            CustomerId: customerId);

        return await processor.ChargeAsync(request);
    }
}

Compare this to the original CheckoutService at the top of the article. No conditional branches. No vendor-specific types leaking in. No knowledge of cents vs. dollars or multi-step transaction flows. The adapter pattern pushed all of that complexity behind the interface boundary where it belongs.

Testing the Adapter Layer

Unit tests for adapters verify that the translation logic works correctly. You test each adapter in isolation against its specific vendor API. Here's how to test both adapters:

public sealed class StripePaymentAdapterTests
{
    [Fact]
    public async Task ChargeAsync_ValidRequest_ReturnsSuccess()
    {
        var stripeApi = new StripeApi();
        var adapter = new StripePaymentAdapter(stripeApi);

        var request = new PaymentRequest(
            Amount: 49.99m,
            Currency: "usd",
            CustomerId: "cust_123");

        var result = await adapter.ChargeAsync(request);

        Assert.True(result.IsSuccessful);
        Assert.StartsWith("ch_", result.TransactionId);
        Assert.False(
            string.IsNullOrEmpty(
                result.ProviderReference));
    }

    [Fact]
    public async Task ChargeAsync_ConvertsAmountToCents()
    {
        var stripeApi = new StripeApi();
        var adapter = new StripePaymentAdapter(stripeApi);

        var request = new PaymentRequest(
            Amount: 10.50m,
            Currency: "usd",
            CustomerId: "cust_456");

        var result = await adapter.ChargeAsync(request);

        Assert.True(result.IsSuccessful);
    }

    [Fact]
    public async Task RefundAsync_ValidCharge_ReturnsSuccess()
    {
        var stripeApi = new StripeApi();
        var adapter = new StripePaymentAdapter(stripeApi);

        var chargeResult = await adapter.ChargeAsync(
            new PaymentRequest(25.00m, "usd", "cust_789"));

        var refund = await adapter.RefundAsync(
            chargeResult.TransactionId, 25.00m);

        Assert.True(refund.IsSuccessful);
        Assert.StartsWith("re_", refund.RefundId);
    }
}

public sealed class PayPalPaymentAdapterTests
{
    [Fact]
    public async Task ChargeAsync_MultiStepFlow_ReturnsSuccess()
    {
        var paypalApi = new PayPalLegacyApi();
        var adapter = new PayPalPaymentAdapter(paypalApi);

        var request = new PaymentRequest(
            Amount: 75.00m,
            Currency: "usd",
            CustomerId: "buyer_001");

        var result = await adapter.ChargeAsync(request);

        Assert.True(result.IsSuccessful);
        Assert.StartsWith(
            "TXN-", result.TransactionId);
        Assert.StartsWith(
            "PAY-", result.ProviderReference);
    }

    [Fact]
    public async Task RefundAsync_ValidTransaction_ReturnsSuccess()
    {
        var paypalApi = new PayPalLegacyApi();
        var adapter = new PayPalPaymentAdapter(paypalApi);

        var chargeResult = await adapter.ChargeAsync(
            new PaymentRequest(
                50.00m, "usd", "buyer_002"));

        var refund = await adapter.RefundAsync(
            chargeResult.TransactionId, 50.00m);

        Assert.True(refund.IsSuccessful);
        Assert.StartsWith("RFD-", refund.RefundId);
    }
}

Testing the factory ensures that the routing logic selects the correct adapter:

public sealed class PaymentProcessorFactoryTests
{
    [Theory]
    [InlineData(PaymentProvider.Stripe, "Stripe")]
    [InlineData(PaymentProvider.PayPal, "PayPal")]
    public void Create_RegisteredProvider_ReturnsCorrectAdapter(
        PaymentProvider provider,
        string expectedName)
    {
        var processors = new IPaymentProcessor[]
        {
            new StripePaymentAdapter(new StripeApi()),
            new PayPalPaymentAdapter(
                new PayPalLegacyApi())
        };

        var factory = new PaymentProcessorFactory(
            processors);

        var result = factory.Create(provider);

        Assert.Equal(expectedName, result.ProviderName);
    }

    [Fact]
    public void Create_UnregisteredProvider_ThrowsArgumentException()
    {
        var factory = new PaymentProcessorFactory(
            Enumerable.Empty<IPaymentProcessor>());

        Assert.Throws<ArgumentException>(
            () => factory.Create(PaymentProvider.Stripe));
    }
}

Notice that these tests don't mock the vendor APIs. Since our simulated SDKs are deterministic and have no external dependencies, we can test the translation logic directly. In a real project where the vendor SDK makes HTTP calls, you'd extract the SDK behind its own interface so the adapter can be tested with a mock.

Wiring Everything Up with Dependency Injection

The final step is registering everything in the DI container. Because every adapter implements IPaymentProcessor, you register them individually and let the factory collect them:

using Microsoft.Extensions.DependencyInjection;

public static class PaymentServiceRegistration
{
    public static IServiceCollection AddPaymentProcessing(
        this IServiceCollection services)
    {
        services.AddSingleton<StripeApi>();
        services.AddSingleton<PayPalLegacyApi>();

        services.AddSingleton<
            IPaymentProcessor,
            StripePaymentAdapter>();
        services.AddSingleton<
            IPaymentProcessor,
            PayPalPaymentAdapter>();

        services.AddSingleton<
            IPaymentProcessorFactory,
            PaymentProcessorFactory>();

        services.AddTransient<CheckoutService>();

        return services;
    }
}

The key detail is that both adapters are registered against IPaymentProcessor. When the DI container resolves IEnumerable<IPaymentProcessor>, it provides all registered implementations to the factory's constructor. The checkout service receives the factory and never knows which adapters exist -- it just asks for one by provider name.

In your Program.cs or startup configuration, a single call wires it all up:

builder.Services.AddPaymentProcessing();

Adapter Pattern vs. Facade Pattern

If you've worked with the facade pattern, you might wonder how it differs from the adapter pattern. They're related but solve different problems. A facade simplifies a complex subsystem by providing a higher-level interface. An adapter makes an existing interface compatible with one your code already expects. The PayPal adapter is a good example of the distinction -- it doesn't simplify the PayPal API for general use; it specifically translates PayPal's interface into IPaymentProcessor so it can be used interchangeably with Stripe.

In practice, you'll often combine both patterns. A facade might sit in front of your entire payment subsystem, while adapters handle the per-vendor translation underneath. Patterns are tools in a toolbox, and the best architectures use them together where each one addresses a specific concern. For related compositional patterns, check out how the decorator pattern layers behavior onto existing interfaces.

Frequently Asked Questions

When should I use the adapter pattern instead of modifying the original class?

Use the adapter pattern whenever you can't modify the original class -- third-party SDKs, legacy code owned by another team, or generated clients. Even when you can modify the source, the adapter pattern is often better because it keeps the original class stable and puts all translation logic in a dedicated, testable location. The adapter gives you a clear seam between your domain and external dependencies.

How many methods should an adapter's target interface have?

Keep the interface focused on what your application actually needs. If you only need charge and refund operations, don't add void authorization, recurring billing, or dispute management just because the vendor supports them. A lean interface reduces the surface area each adapter must implement and makes the inversion of control boundary clearer.

Can an adapter call multiple methods on the adaptee?

Absolutely. The PayPal adapter in this article does exactly that -- it calls BeginTransaction, SetPayer, and ExecuteTransaction to fulfill a single ChargeAsync call. The adapter's job is to bridge the interface gap, and sometimes that means orchestrating multiple calls on the adaptee to match a single method on the target interface.

Should I use class adapters or object adapters in C#?

Object adapters (using composition) are almost always the right choice in C#. Class adapters rely on multiple inheritance, which C# doesn't support. Even if you could inherit from the adaptee, composition gives you looser coupling and the ability to swap the adaptee instance at runtime. The examples in this article all use object adapters with constructor injection.

How do I add a new payment provider to this system?

Write a new class that implements IPaymentProcessor, inject the new vendor's API via the constructor, and register the adapter in your DI container. The factory automatically picks up the new adapter because it receives all IPaymentProcessor implementations. No existing code changes -- no modifications to the factory, the checkout service, or any other adapter.

What's the difference between the adapter pattern and the strategy pattern?

The strategy pattern lets you swap algorithms that share the same interface by design. The adapter pattern makes incompatible interfaces work together. In this payment example, the adapters happen to look like strategies from the checkout service's perspective, but the distinction matters: the adapter exists because the vendor APIs don't match your interface, while a strategy exists because you deliberately designed multiple interchangeable implementations. These distinctions are explored in more depth across creational design patterns and structural ones.

How do I handle errors from different provider APIs?

Each adapter should catch vendor-specific exceptions and translate them into your domain's error model. You might enrich PaymentResult with an ErrorMessage property or throw a custom PaymentException. The important thing is that vendor-specific error types never leak past the adapter boundary -- calling code should only see your domain types.

Wrapping Up This Adapter Pattern Real-World Example

This implementation shows the adapter pattern doing what it does best -- making incompatible interfaces work together without modifying the original code. We started with a checkout service riddled with conditional branches and vendor-specific logic. We ended with a clean architecture where each payment provider lives behind a focused adapter class, a factory selects the right adapter at runtime, and the checkout service depends on nothing but an interface.

The adapter pattern shines whenever you integrate external systems. Payment providers, email services, cloud storage APIs, notification platforms -- any time you're consuming an API you don't control, an adapter gives you a stable boundary between your domain and the outside world. When the vendor changes their SDK, you update one adapter class. When you swap providers entirely, you write one new adapter and update one DI registration.

Take this payment processing example, replace the simulated APIs with your actual vendor SDKs, and you've got a production-ready integration layer. The adapter pattern keeps your domain code clean, your tests simple, and your architecture open to new providers without touching existing code.

How to Implement Adapter Pattern in C#: Step-by-Step Guide

Learn how to implement adapter pattern in C# with a step-by-step guide covering target interfaces, adaptees, object adapters, and dependency injection registration.

Adapter Design Pattern in C#: Complete Guide with Examples

Master the adapter design pattern in C# with practical examples showing interface conversion, legacy integration, and third-party library wrapping.

Adapter Pattern Best Practices in C#: Code Organization and Maintainability

Master adapter pattern best practices in C# including composition over inheritance, single responsibility, error translation, and organized adapter architectures.

An error has occurred. This application may no longer respond until reloaded. Reload