πŸ—οΈ

Interfaces vs Abstract Classes

Object-Oriented Programming Intermediate 2 min read 300 words

Understanding when to use interfaces versus abstract classes in your design

Interfaces vs Abstract Classes

Both interfaces and abstract classes define contracts, but they serve different purposes and have different capabilities.

Quick Comparison

Feature Interface Abstract Class
Multiple inheritance Yes No
Implementation Default methods only (C# 8+) Can have full implementation
Fields No instance fields Can have fields
Constructors No Yes
Access modifiers Public by default Any access modifier
Versioning Adding members breaks implementers Can add virtual members safely

Interfaces

Interfaces define a contract - a set of methods that implementing classes must provide.

// Interface defines WHAT must be done
public interface IRepository<T>
{
    Task<T> GetByIdAsync(int id);
    Task<List<T>> GetAllAsync();
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(int id);
}

// Multiple interface inheritance is allowed
public interface IAuditable
{
    DateTime CreatedAt { get; }
    DateTime? ModifiedAt { get; }
    string CreatedBy { get; }
}

public interface ISoftDeletable
{
    bool IsDeleted { get; }
    DateTime? DeletedAt { get; }
}

// A class can implement multiple interfaces
public class User : IAuditable, ISoftDeletable
{
    public int Id { get; set; }
    public string Name { get; set; }

    // IAuditable implementation
    public DateTime CreatedAt { get; set; }
    public DateTime? ModifiedAt { get; set; }
    public string CreatedBy { get; set; }

    // ISoftDeletable implementation
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
}

Default Interface Methods (C# 8+)

public interface ILogger
{
    void Log(string message);

    // Default implementation - can be overridden
    void LogWarning(string message) => Log($"WARNING: {message}");
    void LogError(string message) => Log($"ERROR: {message}");
}

public class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine($"[{DateTime.Now}] {message}");
    }
    // Uses default LogWarning and LogError
}

public class FileLogger : ILogger
{
    public void Log(string message) { /* Write to file */ }

    // Override default implementation
    public void LogError(string message)
    {
        Log($"!!! CRITICAL ERROR !!! {message}");
    }
}

Abstract Classes

Abstract classes provide a base implementation that derived classes can extend.

// Abstract class defines WHAT + provides HOW (partially)
public abstract class BaseRepository<T> where T : class
{
    // Protected state that derived classes can access
    protected readonly DbContext _context;
    protected readonly ILogger _logger;

    // Constructor - abstract classes can have constructors
    protected BaseRepository(DbContext context, ILogger logger)
    {
        _context = context;
        _logger = logger;
    }

    // Abstract methods - MUST be implemented
    public abstract Task<T> GetByIdAsync(int id);

    // Virtual methods - CAN be overridden
    public virtual async Task<List<T>> GetAllAsync()
    {
        return await _context.Set<T>().ToListAsync();
    }

    // Concrete methods - shared implementation
    public async Task AddAsync(T entity)
    {
        ValidateEntity(entity);
        await _context.Set<T>().AddAsync(entity);
        await _context.SaveChangesAsync();
        _logger.Log($"Added {typeof(T).Name}");
    }

    // Protected helper method
    protected virtual void ValidateEntity(T entity)
    {
        if (entity == null)
            throw new ArgumentNullException(nameof(entity));
    }
}

// Derived class provides specific implementations
public class ProductRepository : BaseRepository<Product>
{
    public ProductRepository(DbContext context, ILogger logger)
        : base(context, logger) { }

    public override async Task<Product> GetByIdAsync(int id)
    {
        return await _context.Set<Product>()
            .Include(p => p.Category)
            .Include(p => p.Reviews)
            .FirstOrDefaultAsync(p => p.Id == id);
    }

    public override async Task<List<Product>> GetAllAsync()
    {
        // Override to include related data
        return await _context.Set<Product>()
            .Include(p => p.Category)
            .ToListAsync();
    }
}

When to Use Each

Use Interfaces When:

  1. Defining a contract for multiple unrelated classes
  2. Multiple inheritance is needed
  3. Testing/mocking - interfaces are easier to mock
  4. API boundaries - defining public contracts
  5. Loose coupling - depending on abstractions
// Good use of interface - unrelated classes share behavior
public interface IExportable
{
    byte[] Export(string format);
}

public class Report : IExportable
{
    public byte[] Export(string format) { /* ... */ }
}

public class Invoice : IExportable
{
    public byte[] Export(string format) { /* ... */ }
}

public class UserProfile : IExportable
{
    public byte[] Export(string format) { /* ... */ }
}

Use Abstract Classes When:

  1. Sharing implementation across related classes
  2. Template Method pattern - define algorithm skeleton
  3. State sharing - derived classes need access to fields
  4. Versioning - can add members without breaking derived classes
  5. Constructor logic - need initialization in base class
// Good use of abstract class - shared behavior and state
public abstract class Document
{
    protected string _content;
    protected DateTime _createdAt;

    protected Document()
    {
        _createdAt = DateTime.UtcNow;
    }

    // Template method - defines algorithm structure
    public void Process()
    {
        Validate();
        Transform();
        Save();
    }

    protected abstract void Validate();
    protected abstract void Transform();

    protected virtual void Save()
    {
        // Default save implementation
        File.WriteAllText($"doc_{_createdAt:yyyyMMdd}.txt", _content);
    }
}

public class WordDocument : Document
{
    protected override void Validate() { /* Word-specific validation */ }
    protected override void Transform() { /* Word-specific transform */ }
}

public class PdfDocument : Document
{
    protected override void Validate() { /* PDF-specific validation */ }
    protected override void Transform() { /* PDF-specific transform */ }
}

Combining Both

Often the best design uses both:

// Interface for the contract
public interface INotificationService
{
    Task SendAsync(string recipient, string message);
    Task<bool> ValidateRecipientAsync(string recipient);
}

// Abstract class for shared implementation
public abstract class NotificationServiceBase : INotificationService
{
    protected readonly ILogger _logger;

    protected NotificationServiceBase(ILogger logger)
    {
        _logger = logger;
    }

    // Common implementation
    public async Task SendAsync(string recipient, string message)
    {
        if (!await ValidateRecipientAsync(recipient))
        {
            _logger.LogWarning($"Invalid recipient: {recipient}");
            return;
        }

        await SendInternalAsync(recipient, message);
        _logger.Log($"Notification sent to {recipient}");
    }

    // Abstract - must be implemented
    public abstract Task<bool> ValidateRecipientAsync(string recipient);
    protected abstract Task SendInternalAsync(string recipient, string message);
}

// Concrete implementations
public class EmailNotificationService : NotificationServiceBase
{
    public EmailNotificationService(ILogger logger) : base(logger) { }

    public override async Task<bool> ValidateRecipientAsync(string recipient)
    {
        return recipient.Contains("@");
    }

    protected override async Task SendInternalAsync(string recipient, string message)
    {
        // Send email
    }
}

public class SmsNotificationService : NotificationServiceBase
{
    public SmsNotificationService(ILogger logger) : base(logger) { }

    public override async Task<bool> ValidateRecipientAsync(string recipient)
    {
        return recipient.All(char.IsDigit) && recipient.Length >= 10;
    }

    protected override async Task SendInternalAsync(string recipient, string message)
    {
        // Send SMS
    }
}

// Dependency injection uses the interface
services.AddScoped<INotificationService, EmailNotificationService>();

Interview Tips

Common Questions:

  • β€œWhat’s the difference between an interface and an abstract class?”
  • β€œWhen would you use one over the other?”
  • β€œCan you have implementation in an interface?”

Key Points:

  1. Interface: Contract only, multiple inheritance, best for APIs
  2. Abstract class: Partial implementation, single inheritance, best for code sharing
  3. Interfaces are better for dependency injection and testing
  4. Abstract classes are better for template method and shared state
  5. C# 8+ interfaces can have default implementations

Design Guidelines:

  • Prefer interfaces for external APIs
  • Use abstract classes for internal hierarchies
  • Don’t use abstract classes just to share code (use composition)
  • Keep interfaces small and focused (ISP)

πŸ“š Related Articles