Interfaces vs Abstract Classes — Choosing the Right Abstraction

Both interfaces and abstract classes define abstractions that consuming code depends on. Choosing between them is a fundamental design decision. The rule of thumb: use an interface when you are defining a pure contract that multiple unrelated types can satisfy — no shared implementation, no shared state. Use an abstract class when you have a family of closely related types that share meaningful implementation — constructor enforcement, shared fields, template method logic. In practice, well-architected ASP.NET Core applications use both: interfaces for DI contracts, abstract classes for shared base behaviour within a bounded family.

Side-by-Side Comparison

// ── INTERFACE — pure contract, no implementation (except default methods) ─
public interface IPaymentProcessor
{
    Task<PaymentResult> ChargeAsync(decimal amount, string currency, PaymentMethod method);
    Task<RefundResult>  RefundAsync(string transactionId, decimal amount);
    bool                SupportsRefunds { get; }
}

// Multiple unrelated classes can satisfy this contract
public class StripeProcessor   : IPaymentProcessor { /* ... */ }
public class PayPalProcessor   : IPaymentProcessor { /* ... */ }
public class BankTransfer      : IPaymentProcessor { /* ... */ }
// These have NOTHING in common except the contract — interface is correct here

// ── ABSTRACT CLASS — shared implementation for related types ──────────────
public abstract class BasePaymentProcessor : IPaymentProcessor
{
    // Shared state — all processors need this
    protected readonly string _apiKey;
    protected readonly ILogger _logger;
    protected readonly HttpClient _httpClient;

    // Shared constructor — enforces required dependencies
    protected BasePaymentProcessor(string apiKey, ILogger logger, HttpClient httpClient)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(apiKey, nameof(apiKey));
        _apiKey     = apiKey;
        _logger     = logger;
        _httpClient = httpClient;
    }

    // Shared implementation — logging wrapped around all charges
    public async Task<PaymentResult> ChargeAsync(
        decimal amount, string currency, PaymentMethod method)
    {
        _logger.LogInformation("Charging {Amount} {Currency}", amount, currency);
        var result = await ProcessChargeAsync(amount, currency, method);
        _logger.LogInformation("Charge result: {Status}", result.Status);
        return result;
    }

    // Abstract — each concrete class fills in the HOW
    protected abstract Task<PaymentResult> ProcessChargeAsync(
        decimal amount, string currency, PaymentMethod method);

    // Shared refund implementation — most processors use this
    public virtual async Task<RefundResult> RefundAsync(string txId, decimal amount)
    {
        _logger.LogInformation("Refunding {Amount} on {TxId}", amount, txId);
        return await ProcessRefundAsync(txId, amount);
    }

    protected abstract Task<RefundResult> ProcessRefundAsync(string txId, decimal amount);

    // Default: most processors support refunds
    public virtual bool SupportsRefunds => true;
}

// Concrete classes — only implement what is different
public class StripeProcessor : BasePaymentProcessor
{
    public StripeProcessor(string apiKey, ILogger logger, HttpClient http)
        : base(apiKey, logger, http) { }

    protected override async Task<PaymentResult> ProcessChargeAsync(
        decimal amount, string currency, PaymentMethod method)
    {
        // Stripe-specific charge logic using _apiKey and _httpClient
        return new PaymentResult("stripe_tx_123", "succeeded");
    }

    protected override async Task<RefundResult> ProcessRefundAsync(string txId, decimal amount)
        => new RefundResult(txId, "refunded");
}

record PaymentResult(string TransactionId, string Status);
record RefundResult(string TransactionId, string Status);
Note: Notice that BasePaymentProcessor implements IPaymentProcessor. This is a common and powerful pattern — the abstract class satisfies the interface contract (partially or fully), and concrete derived classes fill in the abstract pieces. Consuming code depends on IPaymentProcessor, not on BasePaymentProcessor. The abstract class is an implementation detail of the concrete processors, invisible to the caller. This layering gives you both the flexibility of interfaces (swap any concrete implementation) and the code reuse of abstract classes.
Tip: Apply the Interface Segregation Principle (ISP) by splitting large interfaces into small, focused ones. Instead of one IUserService with 15 methods, create IUserReader (GetById, GetAll, Search), IUserWriter (Create, Update, Delete), and IUserAuthentication (Login, Logout, ChangePassword). Services that only need to read users depend only on IUserReader, making them lighter to test. This design also maps naturally to CQRS (Command Query Responsibility Segregation) used in the Part 9 Capstone.
Warning: Do not create a one-to-one interface for every class “just in case” — this is over-engineering. An interface only adds value when you have (or reasonably anticipate) multiple implementations, or when you need to test the consuming class in isolation. A SlugGenerator that converts a title to a URL slug never needs an ISlugGenerator interface — it is a pure function with no dependencies. Create interfaces at architectural boundaries (between layers) and where you genuinely need swappability.

Decision Matrix

Factor Use Interface Use Abstract Class
Shared state needed No Yes
Constructor enforcement No (no constructors) Yes
Multiple implementors (unrelated) Yes Possible but unusual
Single inheritance needed No constraint Yes (only one base)
Shared algorithm/template method No (without default methods) Yes
DI / testability contract Yes — primary use Possible but heavier
Framework extension point Both work Better if shared logic needed

Common Mistakes

Mistake 1 — Giving every class its own interface (YAGNI violation)

❌ Wrong — meaningless interface with only one possible implementation:

public interface ISlugGenerator { string Generate(string title); }
public class SlugGenerator : ISlugGenerator { ... }
// Only ever one implementation — interface adds no value

✅ Correct — create interfaces at real boundaries, not on every class.

Mistake 2 — Fat interface (Interface Segregation violation)

❌ Wrong — one interface with 15 unrelated methods; implementors are forced to stub most of them.

✅ Correct — split into small, focused interfaces; callers depend only on what they need.

🧠 Test Yourself

You are building a reporting module that reads user data. It should never modify users. Which abstraction gives you the best design?