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

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

Integrating third-party libraries, legacy code, and external services into your application means bridging incompatible interfaces constantly. The adapter pattern handles that translation, but a careless implementation creates its own maintenance problems. Adapters accumulate logic that doesn't belong to them. Inheritance chains make swapping implementations painful. Exceptions from external systems leak through your domain layer. Adapter pattern best practices in C# tackle these hazards directly -- they guide you toward adapters that stay thin, testable, and easy to replace when the underlying dependency changes.

This guide covers the practical adapter pattern decisions that determine whether your code remains a clean integration seam or degrades into a tangled translation layer. We'll work through composition versus inheritance, single responsibility, error translation, naming conventions, dependency injection registration, testing strategies, and project organization. Every section includes focused C# examples so you can see the difference between a well-structured adapter pattern approach and one heading for trouble.

Prefer Composition Over Inheritance

The classic adapter pattern tutorial shows two variants: class adapters using inheritance and object adapters using composition. In C#, the composition-based adapter pattern wins almost every time. Class adapters inherit from the adaptee, which couples your adapter to the concrete implementation and blocks you from adapting multiple classes behind a single interface. Object adapters hold a reference to the adaptee, keeping the relationship explicit and swappable.

Here's the inheritance-based approach and its problems:

// Avoid: class adapter via inheritance
public class LegacyPrinterAdapter : LegacyPrinter, IPrinter
{
    public void Print(Document document)
    {
        // Directly calling inherited method
        base.PrintText(document.Content);
    }
}

This adapter inherits every public member of LegacyPrinter, exposing methods your consumers should never call. It also prevents you from adapting a different printer implementation without creating a separate class hierarchy.

The composition-based adapter pattern alternative keeps the surface area minimal:

// Prefer: object adapter via composition
public sealed class LegacyPrinterAdapter : IPrinter
{
    private readonly LegacyPrinter _legacyPrinter;

    public LegacyPrinterAdapter(LegacyPrinter legacyPrinter)
    {
        _legacyPrinter = legacyPrinter
            ?? throw new ArgumentNullException(nameof(legacyPrinter));
    }

    public void Print(Document document)
    {
        _legacyPrinter.PrintText(document.Content);
    }
}

The adapter only exposes what IPrinter defines. Nothing from LegacyPrinter leaks through. You can mark the class sealed because there's no reason to inherit from an adapter -- doing so typically signals a design problem. This composition-based adapter pattern approach also plays well with dependency injection since the adaptee arrives through the constructor and can itself be resolved from the container.

Keep Adapters Single-Responsibility

An adapter pattern class has one job: translate interface A into interface B. The moment you add caching, logging, validation, or business logic inside the adapter, you've given it responsibilities that belong elsewhere. These extras make the adapter harder to test and harder to replace.

Consider an adapter that starts simple but accumulates concerns:

// Avoid: adapter doing too much
public sealed class WeatherServiceAdapter : IWeatherProvider
{
    private readonly ExternalWeatherApi _api;
    private readonly Dictionary<string, WeatherData> _cache = new();
    private readonly ILogger<WeatherServiceAdapter> _logger;

    public WeatherServiceAdapter(
        ExternalWeatherApi api,
        ILogger<WeatherServiceAdapter> logger)
    {
        _api = api;
        _logger = logger;
    }

    public WeatherData GetWeather(string city)
    {
        if (_cache.TryGetValue(city, out var cached))
        {
            return cached;
        }

        _logger.LogInformation("Fetching weather for {City}", city);
        var raw = _api.FetchCurrentConditions(city);

        // Business rule buried in adapter
        if (raw.TemperatureFahrenheit > 120)
        {
            throw new InvalidOperationException("Unrealistic reading");
        }

        var result = new WeatherData(
            raw.TemperatureFahrenheit,
            raw.Humidity);

        _cache[city] = result;
        return result;
    }
}

The adapter pattern implementation is now a cache, a logger, and a validator all at once. Pull each concern into its own layer. The adapter translates. A decorator handles caching. Logging goes into middleware or a separate decorator. Validation lives in the domain layer.

// Prefer: adapter only translates
public sealed class WeatherServiceAdapter : IWeatherProvider
{
    private readonly ExternalWeatherApi _api;

    public WeatherServiceAdapter(ExternalWeatherApi api)
    {
        _api = api ?? throw new ArgumentNullException(nameof(api));
    }

    public WeatherData GetWeather(string city)
    {
        var raw = _api.FetchCurrentConditions(city);
        return new WeatherData(
            raw.TemperatureFahrenheit,
            raw.Humidity);
    }
}

The adapter does exactly one thing: convert ExternalWeatherApi responses into WeatherData. Caching, logging, and validation are separate responsibilities that can each be tested and replaced independently. This single-responsibility adapter pattern approach aligns with the principles behind inversion of control -- your domain depends on abstractions, and each concrete implementation addresses a single concern.

Translate Errors and Exceptions

External systems throw exceptions your domain layer shouldn't know about. A HttpRequestException from a REST client or a SocketException from a TCP library carries implementation details that leak through your abstraction boundary if you let them propagate. The adapter pattern is the right place to catch these and translate them into domain-relevant exceptions.

Here's an adapter that lets external exceptions escape:

// Avoid: external exceptions leaking through
public sealed class PaymentGatewayAdapter : IPaymentProcessor
{
    private readonly StripeClient _stripe;

    public PaymentGatewayAdapter(StripeClient stripe)
    {
        _stripe = stripe;
    }

    public PaymentResult Charge(decimal amount, string token)
    {
        // StripeException propagates to callers
        var response = _stripe.CreateCharge(amount, token);
        return new PaymentResult(response.Id, response.Status);
    }
}

Callers of IPaymentProcessor now need to catch StripeException, which defeats the purpose of having an abstraction. Translate exceptions at the adapter pattern boundary:

// Prefer: translate exceptions into domain types
public sealed class PaymentGatewayAdapter : IPaymentProcessor
{
    private readonly StripeClient _stripe;

    public PaymentGatewayAdapter(StripeClient stripe)
    {
        _stripe = stripe
            ?? throw new ArgumentNullException(nameof(stripe));
    }

    public PaymentResult Charge(decimal amount, string token)
    {
        try
        {
            var response = _stripe.CreateCharge(amount, token);
            return new PaymentResult(response.Id, response.Status);
        }
        catch (StripeException ex) when (ex.Code == "card_declined")
        {
            throw new PaymentDeclinedException(
                "Card was declined",
                ex);
        }
        catch (StripeException ex)
        {
            throw new PaymentProcessingException(
                "Payment processing failed",
                ex);
        }
    }
}

The adapter catches provider-specific exceptions and wraps them in domain exceptions that callers understand. The original exception goes into InnerException so debugging still has full context. This adapter pattern error translation keeps your business logic free from any knowledge about which payment provider you're using -- a key advantage when you need to swap providers later.

Follow Consistent Naming Conventions

Adapter pattern naming should communicate three things at a glance: what it adapts, what it adapts to, and that it's an adapter. Inconsistent names make it hard to locate adapters in a large codebase and obscure the dependency graph.

A naming convention that works well for adapter pattern classes in practice:

// Pattern: {Adaptee}Adapter or {Provider}{Target}Adapter
public sealed class StripePaymentAdapter : IPaymentProcessor { }
public sealed class SendGridEmailAdapter : IEmailSender { }
public sealed class LegacyInventoryAdapter : IInventoryService { }

The provider or system name comes first, followed by the domain concept or Adapter suffix. This groups adapters visually when you sort alphabetically and makes Find All References on the adapted interface immediately useful.

Avoid generic names that hide what the adapter wraps:

// Avoid: vague naming
public sealed class PaymentService : IPaymentProcessor { }
public sealed class EmailHelper : IEmailSender { }
public sealed class DataAccess : IInventoryService { }

These names tell you nothing about the external system being adapted. When you need to replace Stripe with a different provider, you want to find StripePaymentAdapter instantly -- not read through PaymentService hoping the class comment mentions Stripe. Picking up good adapter pattern naming habits is part of understanding the broader landscape of design patterns and writing code that communicates intent.

Register Adapters Properly in Dependency Injection

Adapter pattern classes sit at the boundary between your domain and external systems, making their DI registration critical. Register adapters against the target interface, not the concrete adapter type. This lets consumers depend on the abstraction while the container handles wiring.

// Prefer: register against the target interface
services.AddScoped<IPaymentProcessor, StripePaymentAdapter>();
services.AddScoped<IEmailSender, SendGridEmailAdapter>();
services.AddScoped<IInventoryService, LegacyInventoryAdapter>();

When you need to swap implementations -- say, replacing Stripe with a different payment provider -- you change exactly one line in your composition root. No consumer code changes at all.

For adapters that wrap HttpClient-based services, use the typed client pattern:

services.AddHttpClient<ExternalWeatherApi>(client =>
{
    client.BaseAddress = new Uri("https://api.weather.example.com");
    client.Timeout = TimeSpan.FromSeconds(10);
});

services.AddScoped<IWeatherProvider, WeatherServiceAdapter>();

The container manages the HttpClient lifecycle through IHttpClientFactory and resolves the adapter with the configured client injected. This avoids socket exhaustion issues and keeps your adapter pattern implementation free from HTTP configuration concerns.

Avoid registering adapters as singletons unless the underlying client is genuinely thread-safe and stateless. Most adapter pattern implementations should match the lifetime of the service they wrap. If the adaptee is transient, the adapter should be transient. If it's scoped, the adapter should be scoped. Mismatched lifetimes are a common source of captive dependency bugs that surface as stale data or disposed object exceptions at runtime.

Testing Adapter Classes

Testing adapter pattern classes means verifying the translation logic in isolation. You're not testing the external system -- you're testing that your adapter correctly maps inputs and outputs between your domain types and the adaptee's types. This distinction keeps adapter tests fast, deterministic, and focused.

Start by making the adaptee injectable so you can substitute it in tests:

public sealed class CurrencyConverterAdapter : ICurrencyConverter
{
    private readonly IExchangeRateClient _client;

    public CurrencyConverterAdapter(IExchangeRateClient client)
    {
        _client = client
            ?? throw new ArgumentNullException(nameof(client));
    }

    public decimal Convert(
        decimal amount,
        string from,
        string to)
    {
        var rate = _client.GetRate(from, to);
        return amount * rate;
    }
}

The test verifies the adapter pattern translation without hitting any external API:

[Fact]
public void Convert_WithKnownRate_ReturnsConvertedAmount()
{
    var mockClient = new Mock<IExchangeRateClient>();
    mockClient
        .Setup(c => c.GetRate("USD", "EUR"))
        .Returns(0.85m);

    var adapter = new CurrencyConverterAdapter(mockClient.Object);

    var result = adapter.Convert(100m, "USD", "EUR");

    Assert.Equal(85m, result);
}

For error translation, test that adapter-specific exceptions get wrapped correctly:

[Fact]
public void Convert_WhenClientThrows_ThrowsDomainException()
{
    var mockClient = new Mock<IExchangeRateClient>();
    mockClient
        .Setup(c => c.GetRate(It.IsAny<string>(), It.IsAny<string>()))
        .Throws(new HttpRequestException("Service unavailable"));

    var adapter = new CurrencyConverterAdapter(mockClient.Object);

    var ex = Assert.Throws<CurrencyConversionException>(
        () => adapter.Convert(100m, "USD", "EUR"));

    Assert.IsType<HttpRequestException>(ex.InnerException);
}

When the adaptee is a concrete class without an interface, you have a few options for testing your adapter pattern code. You can extract an interface from the adaptee's methods and wrap the concrete class behind it. Alternatively, you can use an integration test that runs against a test double or local instance of the external service. The key is to avoid writing unit tests that instantiate real HTTP clients or database connections -- those belong in integration tests with proper setup and teardown. These testing strategies tie into the broader practice of building systems with clean seams, similar to how a plugin architecture isolates extensibility points behind well-defined contracts.

Organize Adapters in Your Project Structure

Where you put your adapter pattern classes matters. Scattering adapters across feature folders makes it hard to find what's adapted and what's native to your application. A dedicated infrastructure layer or adapters folder keeps the boundary between your domain and external dependencies visible at the file system level.

A common adapter pattern organization for medium-to-large projects:

src/
├── MyApp.Domain/
│   ├── Interfaces/
│   │   ├── IPaymentProcessor.cs
│   │   ├── IEmailSender.cs
│   │   └── IWeatherProvider.cs
│   └── Exceptions/
│       ├── PaymentDeclinedException.cs
│       └── PaymentProcessingException.cs
├── MyApp.Infrastructure/
│   ├── Adapters/
│   │   ├── Payment/
│   │   │   └── StripePaymentAdapter.cs
│   │   ├── Email/
│   │   │   └── SendGridEmailAdapter.cs
│   │   └── Weather/
│   │       └── WeatherServiceAdapter.cs
│   └── DependencyInjection/
│       └── ServiceCollectionExtensions.cs
└── MyApp.Api/
    └── Program.cs

Interfaces live in the domain project. Adapters live in the infrastructure project. The API project references both and wires them together at startup. This enforces a dependency rule: the domain never references infrastructure. Adapter pattern classes implement domain interfaces, but the domain doesn't know or care which concrete adapters exist.

Group adapters by the external system they wrap, not by the domain concept they serve. A Payment/ folder inside Adapters/ contains everything related to the Stripe integration. When you replace Stripe with another provider, you swap out one folder's contents and update the DI registration. This adapter pattern organization is closely related to how the facade pattern simplifies access to complex subsystems -- your adapters act as narrow gateways into external complexity, and grouping them by system keeps that boundary crisp.

For the DI registration, centralize your adapter registrations in an extension method so the API project doesn't need to reference every adapter individually:

// In MyApp.Infrastructure
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddScoped<IPaymentProcessor, StripePaymentAdapter>();
        services.AddScoped<IEmailSender, SendGridEmailAdapter>();
        services.AddScoped<IWeatherProvider, WeatherServiceAdapter>();

        return services;
    }
}
// In Program.cs
builder.Services.AddInfrastructure(builder.Configuration);

This adapter pattern registration approach scales cleanly. As you add more adapters, they register themselves through the infrastructure extension method. Your composition root stays slim, and each project layer maintains a clear dependency direction. Understanding how to structure these registrations connects directly to the creational patterns that govern how objects get instantiated and wired together in your application.

Frequently Asked Questions

Should I use a class adapter or object adapter in C#?

Use object adapters -- the adapter pattern variant based on composition. C# doesn't support multiple class inheritance, so class adapters can only extend one adaptee. Even when that limitation doesn't apply, the class adapter pattern exposes the adaptee's public members through the adapter, breaking encapsulation. Object adapters hold a private reference to the adaptee and only expose what the target interface requires. This gives you the flexibility to swap the adaptee at runtime or through DI and keeps your adapter's public surface area under control.

How do I adapt an async API to a synchronous interface?

Avoid it when possible. Wrapping async calls with .Result or .GetAwaiter().GetResult() risks deadlocks in your adapter code, especially in ASP.NET contexts with synchronization contexts. Instead, update the target interface to be async. If you truly can't change the interface -- perhaps it's defined in a package you don't control -- isolate the sync-over-async call in a single adapter method, document the risk, and verify it works under the concurrency model your application uses. In new adapter pattern code, always define your interfaces with Task-based return types from the start.

When should I use the adapter pattern instead of the facade pattern?

The adapter pattern converts one interface into another so two incompatible types can work together. The facade pattern provides a simplified interface over a complex subsystem. If you have a single class whose methods don't match your domain interface, you need the adapter pattern. If you have a cluster of classes that need to be orchestrated behind a simpler entry point, you need a facade. Sometimes you use both: a facade to simplify a subsystem, and adapters inside the facade to translate individual components.

How many methods should an adapter pattern interface have?

As few as possible. Each method on the interface represents a contract your adapter must fulfill and your tests must cover. Broad interfaces with ten or fifteen methods create brittle adapter pattern implementations that change for unrelated reasons. Apply interface segregation -- split large interfaces into smaller, focused ones. If only some consumers need weather data and others need weather alerts, define IWeatherDataProvider and IWeatherAlertProvider separately. Each adapter can implement one or both, and consumers only depend on the slice they actually use.

Should I write integration tests for adapter pattern classes?

Yes, but they serve a different purpose than unit tests. Unit tests verify translation logic: given this input from the adaptee, the adapter produces this domain output. Integration tests verify that the adapter pattern implementation actually works with the real external system. Run integration tests against a staging environment or test sandbox, not production. Keep them in a separate test project with longer timeouts and environment-specific configuration. Tag them so they don't run in every CI build -- they're slower and depend on external availability.

How do I handle adapters for services that require configuration?

Pass configuration through the constructor alongside the adaptee. Don't have the adapter read from IConfiguration directly -- that couples it to the configuration system. Instead, define a strongly-typed options class and let the DI container bind configuration values to it.

public sealed class StripePaymentAdapter : IPaymentProcessor
{
    private readonly StripeClient _stripe;
    private readonly StripeOptions _options;

    public StripePaymentAdapter(
        StripeClient stripe,
        IOptions<StripeOptions> options)
    {
        _stripe = stripe;
        _options = options.Value;
    }
}

Register the options alongside the adapter: services.Configure<StripeOptions>(configuration.GetSection("Stripe")). This keeps configuration testable and explicit.

Can I combine the adapter pattern with the decorator pattern?

Absolutely, and it's a powerful combination. The adapter pattern translates an external interface into your domain interface. The decorator then wraps that domain interface to add cross-cutting behavior like caching, logging, retry logic, or circuit-breaking. The decorator doesn't know or care whether the inner implementation is an adapter or a native class -- it just works with the domain interface. This keeps each class focused on a single responsibility and lets you compose behaviors without modifying the adapter pattern implementation itself.

Wrapping Up Adapter Pattern Best Practices

Applying these adapter pattern best practices in C# will help you build integration layers that stay clean as your external dependencies evolve. The core themes carry through every section: use composition to keep adapters swappable, limit each adapter to pure translation, catch and rewrap exceptions at the boundary, and organize your adapter pattern code in a dedicated infrastructure layer that your domain never references directly.

Start with the simplest adapter that bridges the interface gap -- a sealed class with a constructor-injected adaptee and one or two translation methods. Add error translation when external exceptions start leaking into your domain. Centralize DI registration in infrastructure extension methods so swapping providers means changing one line. Write unit tests for the adapter pattern translation logic and integration tests for the real connection. The goal isn't architectural elegance for its own sake -- it's an integration layer where every external dependency is isolated, replaceable, and independently testable.

When to Use Adapter Pattern in C#: Decision Guide with Examples

Discover when to use adapter pattern in C# with real decision criteria, use case examples, and guidance on when simpler alternatives work better.

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.

Weekly Recap: Plugin Architecture, Adapter Pattern, and C# Source Generators [April 2026]

This week covers building extensible plugin systems in C#, the adapter design pattern in depth, and practical C# source generator testing. Plus new videos on developer burnout, staying sharp after promotion, and whether Copilot CLI is finally replacing Visual Studio.

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