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);
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.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.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.