Designing Flexible Systems with Interface-Based Architecture in C#

Breaking Free from Tight Coupling

If you've ever hardcoded a payment gateway into your checkout flow, then watched in frustration as business requirements demanded you support three different providers, you know the pain of tight coupling. Your classes become welded together, making every change risky and every test a nightmare. When one concrete class depends directly on another concrete class, you're locked into that implementation forever.

Interface-based architecture solves this by defining contracts between components instead of hard dependencies. When your OrderService depends on an IPaymentProcessor interface rather than a specific StripePaymentProcessor class, you can swap implementations without touching a single line of existing code. This isn't theoretical architecture astronaut stuff - it's practical engineering that saves you time during refactoring, testing, and feature development.

You'll build a payment processing system that supports credit cards, PayPal, and cryptocurrency payments through a clean interface design. Along the way, you'll learn dependency inversion principles, dependency injection patterns, interface segregation techniques, and testing strategies that make your code flexible enough to handle whatever requirements come next.

Understanding Dependency Inversion

The dependency inversion principle states that high-level modules shouldn't depend on low-level modules - both should depend on abstractions. This sounds abstract until you see the concrete pain it solves. When your business logic directly instantiates database classes, email services, or payment gateways, you can't change those implementations without rewriting business code.

Let's look at what happens when you tightly couple your order processing to a specific payment provider. This pattern might seem fine at first, but it creates maintenance headaches that compound over time as your system grows.

OrderService.cs - Tightly coupled design
public class OrderService
{
    // Direct dependency on concrete class
    private readonly StripePaymentProcessor _paymentProcessor;

    public OrderService()
    {
        // Creating dependencies inside the class
        _paymentProcessor = new StripePaymentProcessor();
    }

    public async Task<OrderResult> ProcessOrderAsync(Order order)
    {
        // Validate order
        if (!ValidateOrder(order))
            return OrderResult.Failed("Invalid order");

        // Hardcoded to use Stripe
        var payment = new PaymentRequest
        {
            Amount = order.Total,
            Currency = "USD",
            CustomerId = order.CustomerId
        };

        var result = await _paymentProcessor.ChargeAsync(payment);

        if (result.Success)
        {
            order.Status = OrderStatus.Completed;
            return OrderResult.Success(order.Id);
        }

        return OrderResult.Failed(result.ErrorMessage);
    }

    private bool ValidateOrder(Order order) =>
        order != null && order.Total > 0;
}

This code works, but it's inflexible. You can't test ProcessOrderAsync without hitting Stripe's API. You can't switch to PayPal without rewriting OrderService. You can't even mock the payment processor for unit tests. Every change to payment processing requires opening this file and risking regression bugs.

The solution is inverting the dependency. Instead of OrderService depending on StripePaymentProcessor, both should depend on an IPaymentProcessor interface. This abstraction becomes the contract that defines what payment processing means, regardless of implementation.

Defining Clean Interface Contracts

A well-designed interface defines capabilities without exposing implementation details. Think of it as a promise about what a class can do, not how it does it. Your interface should be narrow enough to remain stable but complete enough to be useful. Avoid putting every possible method into one interface - that leads to bloated contracts that force implementers to write methods they don't need.

When designing your payment processor interface, focus on the essential operations that every payment provider must support. Don't include Stripe-specific methods or PayPal-specific configuration. Keep it generic enough that any payment provider can implement it while still being specific enough to be useful.

IPaymentProcessor.cs - Clean interface contract
public interface IPaymentProcessor
{
    // Essential operations every payment provider supports
    Task<PaymentResult> ProcessPaymentAsync(
        decimal amount,
        string currency,
        PaymentDetails details);

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

    Task<bool> ValidatePaymentDetailsAsync(PaymentDetails details);
}

public class PaymentDetails
{
    public string CustomerId { get; set; }
    public string PaymentMethodId { get; set; }
    public Dictionary<string, string> Metadata { get; set; }
}

public class PaymentResult
{
    public bool Success { get; set; }
    public string TransactionId { get; set; }
    public string ErrorMessage { get; set; }
    public DateTime ProcessedAt { get; set; }

    public static PaymentResult Successful(string transactionId) =>
        new PaymentResult
        {
            Success = true,
            TransactionId = transactionId,
            ProcessedAt = DateTime.UtcNow
        };

    public static PaymentResult Failed(string error) =>
        new PaymentResult
        {
            Success = false,
            ErrorMessage = error
        };
}

This interface defines three operations that make sense for any payment provider. The PaymentDetails class uses a metadata dictionary for provider-specific data, so you don't need to add Stripe-specific or PayPal-specific properties to the interface itself. This keeps the contract clean while still allowing flexibility.

Now you can create multiple implementations of this interface, and your OrderService won't know or care which one it's using. That's the power of programming to an interface instead of an implementation.

Design Trade-offs & Alternatives

Before you rush to put interfaces everywhere, understand when they add value and when they add unnecessary complexity. Interfaces aren't free - they add indirection that makes code harder to navigate in your IDE. You can't just F12 to the implementation when there are multiple implementations. You need to weigh the flexibility benefits against the cognitive overhead.

Choose interfaces when you need multiple implementations, plan to mock dependencies for testing, or want to decouple components in different layers of your application. E-commerce systems need different payment processors. Logging systems need different sinks. Email services need different providers. These are perfect interface candidates because you genuinely need the flexibility.

Choose abstract classes when you have shared implementation logic that subclasses should inherit. If your payment processors all need the same validation logic, retry logic, or logging logic, an abstract class makes sense. You can provide the common functionality while still forcing subclasses to implement the provider-specific parts. The limitation is you can only inherit from one abstract class, while you can implement multiple interfaces.

Choose concrete classes when you have a single implementation with no need for substitution. Your configuration loader probably doesn't need an interface. Your string parsing utility probably doesn't need an interface. If you're creating an interface "just in case" you might need flexibility someday, you're probably over-engineering. Add the abstraction when you need it, not speculatively.

Implementing Dependency Injection

Dependency injection is how you deliver interface implementations to the classes that need them. Instead of classes creating their own dependencies with the new keyword, they receive dependencies through their constructor. This pattern is called constructor injection, and it's the most explicit and testable approach to dependency management.

The .NET runtime includes Microsoft.Extensions.DependencyInjection, which handles the complexity of creating objects, resolving dependencies, and managing lifetimes. You register your interfaces and their implementations in a service collection, then the container automatically provides the right implementation when a class requests it.

Implementation examples - Multiple payment processors
// Credit card implementation
public class CreditCardProcessor : IPaymentProcessor
{
    private readonly ILogger<CreditCardProcessor> _logger;

    public CreditCardProcessor(ILogger<CreditCardProcessor> logger)
    {
        _logger = logger;
    }

    public async Task<PaymentResult> ProcessPaymentAsync(
        decimal amount,
        string currency,
        PaymentDetails details)
    {
        _logger.LogInformation(
            "Processing credit card payment: {Amount} {Currency}",
            amount, currency);

        // Simulate credit card processing
        await Task.Delay(100);

        if (amount > 10000)
            return PaymentResult.Failed("Amount exceeds credit card limit");

        return PaymentResult.Successful($"CC-{Guid.NewGuid():N}");
    }

    public async Task<RefundResult> RefundPaymentAsync(
        string transactionId,
        decimal amount)
    {
        await Task.Delay(50);
        return new RefundResult { Success = true };
    }

    public async Task<bool> ValidatePaymentDetailsAsync(PaymentDetails details)
    {
        await Task.CompletedTask;
        return !string.IsNullOrEmpty(details.PaymentMethodId);
    }
}

// PayPal implementation
public class PayPalProcessor : IPaymentProcessor
{
    private readonly ILogger<PayPalProcessor> _logger;

    public PayPalProcessor(ILogger<PayPalProcessor> logger)
    {
        _logger = logger;
    }

    public async Task<PaymentResult> ProcessPaymentAsync(
        decimal amount,
        string currency,
        PaymentDetails details)
    {
        _logger.LogInformation(
            "Processing PayPal payment: {Amount} {Currency}",
            amount, currency);

        await Task.Delay(150);
        return PaymentResult.Successful($"PP-{Guid.NewGuid():N}");
    }

    public async Task<RefundResult> RefundPaymentAsync(
        string transactionId,
        decimal amount)
    {
        await Task.Delay(75);
        return new RefundResult { Success = true };
    }

    public async Task<bool> ValidatePaymentDetailsAsync(PaymentDetails details)
    {
        await Task.CompletedTask;
        return details.Metadata?.ContainsKey("PayPalEmail") == true;
    }
}

public class RefundResult
{
    public bool Success { get; set; }
    public string ErrorMessage { get; set; }
}

Both implementations satisfy the IPaymentProcessor contract, but they handle payment processing differently. The credit card processor checks amount limits. The PayPal processor validates email addresses in metadata. Your OrderService doesn't need to know these details - it just calls ProcessPaymentAsync and gets a result.

Now you can configure which implementation to use through dependency injection registration. Change one line of configuration code and your entire application switches payment providers.

Testing with Interface Mocking

Interfaces make testing dramatically easier because you can replace real implementations with test doubles. Instead of hitting a real payment API during tests, you inject a mock that returns predetermined results. This makes tests fast, reliable, and isolated from external dependencies.

You can use mocking frameworks like Moq or NSubstitute to create fake implementations on the fly. These frameworks generate classes that implement your interface and let you specify what they should return when methods are called. This approach is cleaner than creating hand-written test doubles for every interface.

OrderService.cs - Testable with dependency injection
public class OrderService
{
    private readonly IPaymentProcessor _paymentProcessor;
    private readonly ILogger<OrderService> _logger;

    // Dependencies injected through constructor
    public OrderService(
        IPaymentProcessor paymentProcessor,
        ILogger<OrderService> logger)
    {
        _paymentProcessor = paymentProcessor;
        _logger = logger;
    }

    public async Task<OrderResult> ProcessOrderAsync(Order order)
    {
        _logger.LogInformation("Processing order {OrderId}", order.Id);

        if (!ValidateOrder(order))
        {
            _logger.LogWarning("Order validation failed: {OrderId}", order.Id);
            return OrderResult.Failed("Invalid order data");
        }

        var paymentDetails = new PaymentDetails
        {
            CustomerId = order.CustomerId,
            PaymentMethodId = order.PaymentMethodId,
            Metadata = new Dictionary<string, string>
            {
                ["OrderId"] = order.Id.ToString()
            }
        };

        var result = await _paymentProcessor.ProcessPaymentAsync(
            order.Total,
            order.Currency,
            paymentDetails);

        if (result.Success)
        {
            order.Status = OrderStatus.Completed;
            order.TransactionId = result.TransactionId;
            _logger.LogInformation(
                "Order completed: {OrderId}, Transaction: {TransactionId}",
                order.Id, result.TransactionId);
            return OrderResult.Success(order.Id);
        }

        _logger.LogError(
            "Payment failed for order {OrderId}: {Error}",
            order.Id, result.ErrorMessage);
        return OrderResult.Failed(result.ErrorMessage);
    }

    private bool ValidateOrder(Order order) =>
        order != null &&
        order.Total > 0 &&
        !string.IsNullOrEmpty(order.CustomerId);
}

public class Order
{
    public int Id { get; set; }
    public string CustomerId { get; set; }
    public string PaymentMethodId { get; set; }
    public decimal Total { get; set; }
    public string Currency { get; set; } = "USD";
    public OrderStatus Status { get; set; }
    public string TransactionId { get; set; }
}

public enum OrderStatus
{
    Pending,
    Completed,
    Failed
}

public class OrderResult
{
    public bool Success { get; set; }
    public int? OrderId { get; set; }
    public string ErrorMessage { get; set; }

    public static OrderResult Success(int orderId) =>
        new OrderResult { Success = true, OrderId = orderId };

    public static OrderResult Failed(string error) =>
        new OrderResult { Success = false, ErrorMessage = error };
}

This OrderService is testable because it depends on abstractions. In production, you'll inject a real payment processor. In tests, you'll inject a mock that simulates success or failure scenarios. You control the test outcomes without writing brittle integration tests that depend on external APIs.

Try It Yourself: Complete Payment System

Let's build a complete working application that demonstrates interface-based architecture with dependency injection. You'll create multiple payment processor implementations, configure them in the DI container, and see how easy it is to swap implementations.

Create a new console application and add the Microsoft.Extensions.DependencyInjection package. This example shows how to register different implementations and let the container handle object creation.

PaymentSystem.csproj - Project file
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
  </ItemGroup>
</Project>
Program.cs - Complete working example
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

// Configure dependency injection
var services = new ServiceCollection();

// Register logging
services.AddLogging(builder =>
{
    builder.AddConsole();
    builder.SetMinimumLevel(LogLevel.Information);
});

// Register payment processors - swap these to change behavior
services.AddScoped<IPaymentProcessor, CreditCardProcessor>();
// services.AddScoped<IPaymentProcessor, PayPalProcessor>();
// services.AddScoped<IPaymentProcessor, CryptoProcessor>();

// Register order service
services.AddScoped<OrderService>();

// Build service provider
var serviceProvider = services.BuildServiceProvider();

// Get OrderService from container (it gets IPaymentProcessor automatically)
var orderService = serviceProvider.GetRequiredService<OrderService>();

// Process some test orders
var order1 = new Order
{
    Id = 1001,
    CustomerId = "CUST-001",
    PaymentMethodId = "pm_card_visa",
    Total = 149.99m,
    Currency = "USD"
};

var order2 = new Order
{
    Id = 1002,
    CustomerId = "CUST-002",
    PaymentMethodId = "pm_card_amex",
    Total = 15000m, // Exceeds credit card limit
    Currency = "USD"
};

Console.WriteLine("=== Processing Orders with Interface-Based Architecture ===\n");

var result1 = await orderService.ProcessOrderAsync(order1);
Console.WriteLine($"Order {order1.Id}: {(result1.Success ? "SUCCESS" : "FAILED")}");
if (result1.Success)
    Console.WriteLine($"  Transaction ID: {order1.TransactionId}");
else
    Console.WriteLine($"  Error: {result1.ErrorMessage}");

Console.WriteLine();

var result2 = await orderService.ProcessOrderAsync(order2);
Console.WriteLine($"Order {order2.Id}: {(result2.Success ? "SUCCESS" : "FAILED")}");
if (result2.Success)
    Console.WriteLine($"  Transaction ID: {order2.TransactionId}");
else
    Console.WriteLine($"  Error: {result2.ErrorMessage}");

Console.WriteLine("\n=== Try changing the registered processor in Program.cs ===");

// Crypto processor implementation
public class CryptoProcessor : IPaymentProcessor
{
    private readonly ILogger<CryptoProcessor> _logger;

    public CryptoProcessor(ILogger<CryptoProcessor> logger)
    {
        _logger = logger;
    }

    public async Task<PaymentResult> ProcessPaymentAsync(
        decimal amount,
        string currency,
        PaymentDetails details)
    {
        _logger.LogInformation(
            "Processing cryptocurrency payment: {Amount} {Currency}",
            amount, currency);

        await Task.Delay(200);

        // Crypto has no limits but slower processing
        return PaymentResult.Successful($"CRYPTO-{Guid.NewGuid():N}");
    }

    public async Task<RefundResult> RefundPaymentAsync(
        string transactionId,
        decimal amount)
    {
        await Task.Delay(100);
        _logger.LogInformation("Crypto refund processed: {TransactionId}", transactionId);
        return new RefundResult { Success = true };
    }

    public async Task<bool> ValidatePaymentDetailsAsync(PaymentDetails details)
    {
        await Task.CompletedTask;
        return details.Metadata?.ContainsKey("WalletAddress") == true;
    }
}

To run this example:

  1. Create a new directory and save the .csproj file
  2. Copy all the interface and class definitions from earlier sections
  3. Save the Program.cs file with the complete code above
  4. Run dotnet restore to install packages
  5. Run dotnet run to execute the application

Expected output:

=== Processing Orders with Interface-Based Architecture ===

info: CreditCardProcessor[0]
      Processing credit card payment: 149.99 USD
info: OrderService[0]
      Order completed: 1001, Transaction: CC-7f3b9c...
Order 1001: SUCCESS
  Transaction ID: CC-7f3b9c...

info: CreditCardProcessor[0]
      Processing credit card payment: 15000 USD
warn: OrderService[0]
      Payment failed for order 1002: Amount exceeds credit card limit
Order 1002: FAILED
  Error: Amount exceeds credit card limit

=== Try changing the registered processor in Program.cs ===

Try uncommenting different processor registrations in Program.cs to see how the application behavior changes without modifying OrderService. This demonstrates the power of interface-based architecture - you configure implementations once and the DI container handles everything else.

Choosing the Right Abstraction Level

Interface segregation is about creating focused contracts instead of monolithic ones. When you put every possible method into a single interface, implementers must provide all methods even if they only need a few. This violates the interface segregation principle and creates unnecessary coupling.

Consider splitting a large IPaymentProcessor interface into smaller, focused interfaces. Maybe you have IPaymentProcessor for basic operations, IRefundablePayment for processors that support refunds, and IRecurringPayment for subscription billing. Classes implement only the interfaces they need, and consumers depend only on the capabilities they use.

This approach gives you fine-grained control over dependencies. Your one-time purchase flow only depends on IPaymentProcessor. Your subscription system depends on IPaymentProcessor and IRecurringPayment. If a payment provider doesn't support refunds, it simply doesn't implement IRefundablePayment. The compiler enforces these constraints at build time.

Segregated interfaces - Focused contracts
// Core payment capability
public interface IPaymentProcessor
{
    Task<PaymentResult> ProcessPaymentAsync(
        decimal amount,
        string currency,
        PaymentDetails details);
}

// Optional refund capability
public interface IRefundablePayment
{
    Task<RefundResult> RefundPaymentAsync(
        string transactionId,
        decimal amount);
}

// Optional recurring billing capability
public interface IRecurringPayment
{
    Task<SubscriptionResult> CreateSubscriptionAsync(
        string customerId,
        string planId);

    Task<bool> CancelSubscriptionAsync(string subscriptionId);
}

// Credit cards support all capabilities
public class CreditCardProcessor :
    IPaymentProcessor,
    IRefundablePayment,
    IRecurringPayment
{
    public Task<PaymentResult> ProcessPaymentAsync(
        decimal amount, string currency, PaymentDetails details)
    {
        // Implementation
        throw new NotImplementedException();
    }

    public Task<RefundResult> RefundPaymentAsync(
        string transactionId, decimal amount)
    {
        // Implementation
        throw new NotImplementedException();
    }

    public Task<SubscriptionResult> CreateSubscriptionAsync(
        string customerId, string planId)
    {
        // Implementation
        throw new NotImplementedException();
    }

    public Task<bool> CancelSubscriptionAsync(string subscriptionId)
    {
        // Implementation
        throw new NotImplementedException();
    }
}

// Crypto only supports basic payments
public class CryptoProcessor : IPaymentProcessor
{
    public Task<PaymentResult> ProcessPaymentAsync(
        decimal amount, string currency, PaymentDetails details)
    {
        // Implementation
        throw new NotImplementedException();
    }
}

When migrating from concrete classes to interfaces, do it incrementally. Start by introducing interfaces in the most painful areas - classes that are hard to test or frequently changing implementations. Don't refactor your entire codebase at once. Add interfaces as you touch code, and let the architecture evolve naturally toward better separation of concerns.

Frequently Asked Questions (FAQ)

When should I use interfaces instead of concrete classes?

Use interfaces when you need multiple implementations, want to write testable code with mocking, or need to decouple components. If your class will only have one implementation and no testing requirements, a concrete class might be simpler. Interfaces shine when you need flexibility and loose coupling between system components.

What's the difference between abstract classes and interfaces?

Abstract classes can provide implementation, have fields, and support access modifiers, but you can only inherit from one. Interfaces define contracts with no implementation (except default interface methods in C# 8+), and a class can implement multiple interfaces. Use abstract classes for shared behavior and interfaces for contracts.

How does interface segregation improve design?

Interface segregation prevents clients from depending on methods they don't use. Instead of one large interface, create smaller, focused interfaces. This reduces coupling, makes code easier to test, and allows classes to implement only the interfaces they need. It's better to have many small interfaces than one bloated interface.

Why is dependency injection important with interfaces?

Dependency injection combined with interfaces enables loose coupling, easier testing, and flexible configuration. You can swap implementations without changing consuming code, inject mocks for testing, and configure different implementations for different environments. This pattern is fundamental to building maintainable enterprise applications.

Back to Articles