Designing Abstractions: Abstract Classes vs Interfaces in C#

The Right Abstraction for the Right Job

It's tempting to default to interfaces for all abstractions in modern C#. After all, interfaces support multiple implementation, they're the backbone of dependency injection, and they make testing easier. It works—until you need to add shared behavior across implementations. Suddenly you're duplicating code in every class, or worse, creating helper methods that sidestep the abstraction entirely.

Abstract classes solve this by providing both contract (abstract members) and implementation (concrete methods with shared logic). When types share an "is-a" relationship with common behavior, abstract classes give you a cleaner design than interfaces alone. The trade-off is that C# allows only single inheritance, so you must choose your base class carefully. Interfaces remain superior for defining capabilities that unrelated types can implement.

You'll learn the fundamental differences between abstract classes and interfaces, when to use each, how C# 8's default interface members change the decision, and patterns for combining both. By the end, you'll know exactly which abstraction fits your design needs.

Abstract Classes: Shared Identity and Behavior

An abstract class defines a template that derived classes must follow while providing shared implementation that all descendants inherit. You mark methods abstract when each subclass needs its own implementation, and you provide concrete methods for behavior that's common across all types. Abstract classes can have fields, constructors, and state—everything a regular class has, except you cannot instantiate them directly.

The key insight is that abstract classes represent identity. When you inherit from an abstract class, you're declaring an "is-a" relationship. A SavingsAccount is a BankAccount, so they share common state like balance and account number. Abstract classes enforce this taxonomy and let you reuse code across the hierarchy.

AbstractClassExample.cs
// Abstract base class with shared state and behavior
public abstract class BankAccount
{
    // Shared fields - all accounts have these
    public string AccountNumber { get; protected set; }
    public decimal Balance { get; protected set; }
    public DateTime CreatedDate { get; }

    // Constructor - derived classes must call this
    protected BankAccount(string accountNumber, decimal initialBalance)
    {
        AccountNumber = accountNumber;
        Balance = initialBalance;
        CreatedDate = DateTime.UtcNow;
    }

    // Abstract method - each account type implements differently
    public abstract decimal CalculateInterest();

    // Concrete method - shared across all accounts
    public void Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Deposit amount must be positive");

        Balance += amount;
        LogTransaction("Deposit", amount);
    }

    public virtual bool Withdraw(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Withdrawal amount must be positive");

        if (Balance >= amount)
        {
            Balance -= amount;
            LogTransaction("Withdrawal", amount);
            return true;
        }

        return false;
    }

    protected void LogTransaction(string type, decimal amount)
    {
        Console.WriteLine($"[{CreatedDate:yyyy-MM-dd}] " +
                          $"{type}: ${amount:F2} on account {AccountNumber}");
    }
}

// Concrete derived class
public class SavingsAccount : BankAccount
{
    public decimal InterestRate { get; set; }

    public SavingsAccount(string accountNumber, decimal initialBalance,
                          decimal interestRate)
        : base(accountNumber, initialBalance)
    {
        InterestRate = interestRate;
    }

    public override decimal CalculateInterest()
    {
        return Balance * InterestRate / 100;
    }
}

public class CheckingAccount : BankAccount
{
    public decimal OverdraftLimit { get; set; }

    public CheckingAccount(string accountNumber, decimal initialBalance,
                           decimal overdraftLimit)
        : base(accountNumber, initialBalance)
    {
        OverdraftLimit = overdraftLimit;
    }

    public override decimal CalculateInterest()
    {
        // Checking accounts don't earn interest
        return 0;
    }

    public override bool Withdraw(decimal amount)
    {
        // Allow overdraft up to limit
        if (Balance + OverdraftLimit >= amount)
        {
            Balance -= amount;
            LogTransaction("Withdrawal", amount);
            return true;
        }

        return false;
    }
}

The BankAccount base class provides shared fields, a constructor to initialize them, and concrete methods like Deposit that work identically for all accounts. Each derived class implements CalculateInterest differently because interest calculation varies by account type. CheckingAccount overrides Withdraw to support overdraft protection. This combination of shared and specialized behavior is what abstract classes excel at.

Interfaces: Capabilities Without Identity

Interfaces define contracts—they specify what methods a type must provide without dictating how or storing any state. A class can implement multiple interfaces, which makes them perfect for defining capabilities that unrelated types can share. Something that's IDisposable can clean up resources, something that's IComparable can be sorted, and something that's IEnumerable can be iterated. These capabilities don't imply shared identity.

Prior to C# 8, interfaces were pure contracts with zero implementation. Now you can add default implementations to interface members, which blurs the line with abstract classes. However, interfaces still cannot have instance fields or constructors, and they primarily represent "can-do" relationships rather than "is-a" hierarchies.

InterfaceExample.cs
// Interface defining a capability
public interface INotifiable
{
    void SendNotification(string message);
}

// Interface with default implementation (C# 8+)
public interface ILoggable
{
    void Log(string message);

    // Default implementation - optional to override
    void LogError(string error)
    {
        Log($"ERROR: {error}");
    }

    void LogWarning(string warning)
    {
        Log($"WARNING: {warning}");
    }
}

// Class implementing multiple interfaces
public class EmailService : INotifiable, ILoggable
{
    private readonly string _smtpServer;

    public EmailService(string smtpServer)
    {
        _smtpServer = smtpServer;
    }

    // Required by INotifiable
    public void SendNotification(string message)
    {
        Console.WriteLine($"Sending email via {_smtpServer}: {message}");
    }

    // Required by ILoggable
    public void Log(string message)
    {
        Console.WriteLine($"[Email Service] {message}");
    }

    // Optionally override default implementation
    public void LogError(string error)
    {
        Console.WriteLine($"[Email Service ERROR] {error}");
        SendNotification($"Error occurred: {error}");
    }

    // LogWarning uses the default interface implementation
}

// Another unrelated class implementing same interfaces
public class SmsService : INotifiable, ILoggable
{
    private readonly string _apiKey;

    public SmsService(string apiKey)
    {
        _apiKey = apiKey;
    }

    public void SendNotification(string message)
    {
        Console.WriteLine($"Sending SMS via API: {message}");
    }

    public void Log(string message)
    {
        Console.WriteLine($"[SMS Service] {message}");
    }
}

// Usage demonstrating polymorphism
public class NotificationManager
{
    private readonly List _services = new();

    public void RegisterService(INotifiable service)
    {
        _services.Add(service);
    }

    public void BroadcastNotification(string message)
    {
        foreach (var service in _services)
        {
            service.SendNotification(message);
        }
    }
}

EmailService and SmsService are completely unrelated types—they don't share identity or common base class. Yet both implement INotifiable and ILoggable, giving them notification and logging capabilities. The NotificationManager works with any INotifiable, treating EmailService and SmsService polymorphically without needing a shared base class. This flexibility is what makes interfaces powerful for defining cross-cutting capabilities.

Core Differences in Practice

The inheritance model is the most fundamental difference. C# allows a class to inherit from only one base class (abstract or concrete), but implement unlimited interfaces. This means abstract classes must represent your primary taxonomy—the core "is-a" relationship. Interfaces add secondary capabilities that can span unrelated hierarchies.

Abstract classes can have instance fields, constructors, and state. You can define protected members, use access modifiers, and share field values across derived classes. Interfaces cannot have instance state (though they can have static members). This makes abstract classes the natural choice when your types need shared data like cache, connection pools, or configuration.

CombinedExample.cs
// Abstract class for shared identity and state
public abstract class Vehicle
{
    protected string _vin;
    protected int _currentSpeed;

    public string VIN => _vin;
    public int MaxSpeed { get; protected set; }

    protected Vehicle(string vin, int maxSpeed)
    {
        _vin = vin;
        MaxSpeed = maxSpeed;
    }

    public abstract void Start();
    public abstract void Stop();

    public void Accelerate(int increment)
    {
        _currentSpeed = Math.Min(_currentSpeed + increment, MaxSpeed);
        Console.WriteLine($"Speed: {_currentSpeed} mph");
    }
}

// Interfaces for capabilities
public interface IElectric
{
    int BatteryLevel { get; }
    void Charge();
}

public interface IGpsEnabled
{
    (double Lat, double Lon) GetCurrentLocation();
}

// Concrete class combining abstract class and interfaces
public class ElectricCar : Vehicle, IElectric, IGpsEnabled
{
    public int BatteryLevel { get; private set; }

    public ElectricCar(string vin) : base(vin, maxSpeed: 120)
    {
        BatteryLevel = 100;
    }

    public override void Start()
    {
        if (BatteryLevel > 0)
            Console.WriteLine("Electric motor started silently");
        else
            Console.WriteLine("Battery depleted, cannot start");
    }

    public override void Stop()
    {
        Console.WriteLine("Electric motor stopped");
    }

    public void Charge()
    {
        BatteryLevel = 100;
        Console.WriteLine("Battery fully charged");
    }

    public (double Lat, double Lon) GetCurrentLocation()
    {
        return (37.7749, -122.4194); // Simulated GPS
    }
}

// Gas car shares Vehicle base but different capabilities
public class GasCar : Vehicle, IGpsEnabled
{
    private double _fuelLevel;

    public GasCar(string vin) : base(vin, maxSpeed: 140)
    {
        _fuelLevel = 15.0;
    }

    public override void Start()
    {
        Console.WriteLine("Engine started with combustion");
    }

    public override void Stop()
    {
        Console.WriteLine("Engine stopped");
    }

    public (double Lat, double Lon) GetCurrentLocation()
    {
        return (34.0522, -118.2437);
    }
}

Both ElectricCar and GasCar inherit from Vehicle, sharing fields like VIN and CurrentSpeed, plus common behavior like Accelerate. They implement different interfaces based on their capabilities—ElectricCar implements IElectric because it has a battery, while GasCar doesn't. Both implement IGpsEnabled. This pattern combines the "is-a" relationship of inheritance with the "can-do" flexibility of interfaces.

Comparing Approaches

Choose abstract classes if your types share substantial common state or behavior—you gain code reuse and enforced initialization through base constructors but lose the ability to inherit from other classes. Abstract classes work best for hierarchies where types have a clear "is-a" relationship, like all shapes having area calculation or all database connections having connection strings.

Choose interfaces if you need multiple unrelated types to share a capability—it favors composition and flexibility while avoiding rigid hierarchies. Interfaces excel when types from different domains need the same contract, like IDisposable for cleanup across file handles, database connections, and network sockets. Since C# 8, default interface members reduce duplication for helper methods.

Combine both when you have a primary taxonomy (abstract class) with cross-cutting concerns (interfaces). A typical pattern is abstract class for core behavior, interfaces for optional features. For example, document types inherit from Document but implement IEncryptable, ICompressible, or IVersioned based on their needs. This gives you both reuse and flexibility.

If unsure, start with interfaces and extract abstract classes when duplication emerges. It's easier to add a base class later than to refactor away from one. Monitor your code reviews—if multiple implementers of an interface copy-paste the same logic, that's a signal you need an abstract class to capture the shared behavior.

Hands-On Comparison

This example shows both approaches side by side, demonstrating when each makes sense. You'll implement a simple plugin system using interfaces, then extend it with an abstract base class for shared functionality.

Steps

  1. Generate: dotnet new console -n AbstractVsInterface
  2. Change directory: cd AbstractVsInterface
  3. Modify Program.cs with the code below
  4. Confirm AbstractVsInterface.csproj matches
  5. Run: dotnet run
AbstractVsInterface.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
// Interface approach: Pure contract
interface IPlugin
{
    string Name { get; }
    void Execute();
}

// Abstract class approach: Shared behavior
abstract class BasePlugin
{
    public string Name { get; }
    protected DateTime StartTime;

    protected BasePlugin(string name)
    {
        Name = name;
    }

    public void Execute()
    {
        StartTime = DateTime.UtcNow;
        Console.WriteLine($"[{Name}] Starting...");
        Run();
        var elapsed = DateTime.UtcNow - StartTime;
        Console.WriteLine($"[{Name}] Completed in {elapsed.TotalMilliseconds}ms\n");
    }

    protected abstract void Run();
}

// Implementations
class EmailPlugin : BasePlugin
{
    public EmailPlugin() : base("Email Processor") { }
    protected override void Run()
    {
        Thread.Sleep(50);
        Console.WriteLine("  - Sending 3 emails");
    }
}

class DataPlugin : BasePlugin
{
    public DataPlugin() : base("Data Sync") { }
    protected override void Run()
    {
        Thread.Sleep(100);
        Console.WriteLine("  - Syncing 1000 records");
    }
}

// Demo
Console.WriteLine("=== Abstract Class Pattern ===");
List plugins = new() { new EmailPlugin(), new DataPlugin() };
plugins.ForEach(p => p.Execute());

What you'll see

=== Abstract Class Pattern ===
[Email Processor] Starting...
  - Sending 3 emails
[Email Processor] Completed in 52ms

[Data Sync] Starting...
  - Syncing 1000 records
[Data Sync] Completed in 102ms

The abstract BasePlugin class provides timing logic shared across all plugins. Each plugin implements only the core Run method specific to its purpose. This demonstrates how abstract classes eliminate duplication when types share behavior, while interfaces (shown in the comments) provide pure contracts without any implementation baggage.

FAQ

Can a class implement multiple interfaces but only inherit one abstract class?

Yes. C# supports single inheritance for classes but multiple interface implementation. A class can inherit one abstract class and implement many interfaces. Use interfaces for capabilities a type can have; use abstract classes for types that share an "is-a" relationship with common behavior.

What are default interface members and when should I use them?

Introduced in C# 8, default interface members provide implementations in interfaces. Use them to evolve interfaces without breaking existing implementers. They're best for adding optional behavior to established interfaces. For new designs, abstract classes remain clearer for shared implementations.

Can abstract classes have constructors?

Yes. Abstract classes can have constructors to initialize fields and enforce invariants. Derived classes must call the base constructor explicitly. Interfaces cannot have constructors because they don't hold state. Use abstract class constructors to validate common data across all derived types.

Should I convert my abstract classes to interfaces with default members?

No. Don't refactor working abstract classes just to use C# 8 features. Abstract classes remain the right choice when you need state, constructors, or clear "is-a" relationships. Reserve default interface members for evolving existing interfaces or when you need multiple inheritance of behavior.

How do I test code that depends on abstract classes vs interfaces?

Both are mockable. Interfaces are slightly easier to mock because they have no implementation. For abstract classes, you either create test-specific derived classes or use mocking frameworks that can stub abstract members. Design for testability by depending on abstractions, not concrete classes.

Back to Articles