Interfaces and Abstract Classes in C#
Introduction
Interfaces and abstract classes are fundamental tools for abstraction in C#. Understanding when to use each, and how theyβve evolved in modern C#, is essential for designing flexible and maintainable systems.
Table of Contents
- Interface Fundamentals
- Abstract Class Fundamentals
- Decision Matrix
- Modern Interface Features
- Multiple Interface Implementation
- Explicit Interface Implementation
- Interface Versioning
- Marker Interfaces and Alternatives
- Interview Questions
Interface Fundamentals
An interface defines a contract - what a type can do, without specifying how.
Basic Interface Definition
// Interface defines capabilities
public interface IPaymentProcessor
{
// Method signatures
Task<PaymentResult> ProcessAsync(decimal amount);
Task<RefundResult> RefundAsync(string transactionId);
// Properties
string ProviderName { get; }
bool IsAvailable { get; }
// Events
event EventHandler<PaymentEventArgs> PaymentCompleted;
}
// Implementation
public class StripePaymentProcessor : IPaymentProcessor
{
public string ProviderName => "Stripe";
public bool IsAvailable => true;
public event EventHandler<PaymentEventArgs> PaymentCompleted;
public async Task<PaymentResult> ProcessAsync(decimal amount)
{
// Stripe-specific implementation
var result = new PaymentResult { Success = true };
PaymentCompleted?.Invoke(this, new PaymentEventArgs(result));
return result;
}
public async Task<RefundResult> RefundAsync(string transactionId)
{
// Stripe-specific refund logic
return new RefundResult { Success = true };
}
}
Interface Inheritance
// Interfaces can inherit from other interfaces
public interface IEntity
{
int Id { get; }
}
public interface IAuditable
{
DateTime CreatedAt { get; }
DateTime? ModifiedAt { get; }
string CreatedBy { get; }
string? ModifiedBy { get; }
}
public interface ISoftDeletable
{
bool IsDeleted { get; }
DateTime? DeletedAt { get; }
}
// Combined interface
public interface IAuditableEntity : IEntity, IAuditable, ISoftDeletable
{
// Inherits all members from parent interfaces
}
// Implementing class must satisfy all interfaces
public class Document : IAuditableEntity
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public string CreatedBy { get; set; }
public string? ModifiedBy { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string Title { get; set; }
public string Content { get; set; }
}
Abstract Class Fundamentals
An abstract class provides a partial implementation that derived classes complete.
Basic Abstract Class
public abstract class Shape
{
// Fields can have any access modifier
protected string _name;
// Constructor (called by derived classes)
protected Shape(string name)
{
_name = name;
}
// Abstract members - must be implemented
public abstract double CalculateArea();
public abstract double CalculatePerimeter();
// Virtual members - can be overridden
public virtual void Display()
{
Console.WriteLine($"Shape: {_name}");
Console.WriteLine($"Area: {CalculateArea():F2}");
Console.WriteLine($"Perimeter: {CalculatePerimeter():F2}");
}
// Concrete members - shared implementation
public string GetName() => _name;
}
public class Circle : Shape
{
public double Radius { get; }
public Circle(double radius) : base("Circle")
{
Radius = radius;
}
// Must implement abstract members
public override double CalculateArea() => Math.PI * Radius * Radius;
public override double CalculatePerimeter() => 2 * Math.PI * Radius;
// Optionally override virtual members
public override void Display()
{
base.Display();
Console.WriteLine($"Radius: {Radius:F2}");
}
}
Abstract Properties
public abstract class Vehicle
{
// Abstract property - no implementation
public abstract int NumberOfWheels { get; }
// Abstract property with getter and setter
public abstract string LicensePlate { get; set; }
// Virtual property - has default implementation
public virtual int MaxSpeed => 100;
// Concrete property
public string Manufacturer { get; set; }
}
public class Car : Vehicle
{
private string _licensePlate;
public override int NumberOfWheels => 4;
public override string LicensePlate
{
get => _licensePlate;
set => _licensePlate = value?.ToUpper();
}
public override int MaxSpeed => 200;
}
public class Motorcycle : Vehicle
{
public override int NumberOfWheels => 2;
public override string LicensePlate { get; set; }
public override int MaxSpeed => 250;
}
Decision Matrix
When to Use Interface vs Abstract Class
| Scenario | Interface | Abstract Class |
|---|---|---|
| Define a contract only | β | β |
| Share code implementation | β (use DIM*) | β |
| Multiple inheritance | β | β |
| Non-public members | β | β |
| Fields/state | β | β |
| Constructors | β | β |
| Unrelated classes share behavior | β | β |
| Related classes in hierarchy | β | β |
| Versioning/adding members | β (DIM*) | β οΈ Risk breaking |
*DIM = Default Interface Methods (C# 8+)
Quick Decision Guide
Does the abstraction represent:
βββ A capability/behavior? β Interface (IDisposable, IComparable)
β Examples: Can be serialized, Can be compared, Can send notifications
β
βββ A base type in a hierarchy? β Abstract Class
β Examples: Shape β Circle/Rectangle, Animal β Dog/Cat
β
βββ Something unrelated classes share? β Interface
β Examples: Both FileLogger and DatabaseLogger can log
β
βββ Something that needs shared state/code? β Abstract Class
Examples: Base repository with connection string
Real-World Example
// Interface: Defines what can be done (capability)
public interface INotificationSender
{
Task SendAsync(string recipient, string message);
}
// Abstract class: Shares implementation among related types
public abstract class NotificationSenderBase : INotificationSender
{
protected readonly ILogger _logger;
protected readonly IConfiguration _config;
protected NotificationSenderBase(ILogger logger, IConfiguration config)
{
_logger = logger;
_config = config;
}
// Template method pattern
public async Task SendAsync(string recipient, string message)
{
_logger.LogInformation("Sending notification to {Recipient}", recipient);
try
{
await SendCoreAsync(recipient, message);
_logger.LogInformation("Notification sent successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send notification");
throw;
}
}
// Derived classes implement the actual sending
protected abstract Task SendCoreAsync(string recipient, string message);
}
// Concrete implementations
public class EmailSender : NotificationSenderBase
{
private readonly ISmtpClient _smtpClient;
public EmailSender(ISmtpClient smtpClient, ILogger logger, IConfiguration config)
: base(logger, config)
{
_smtpClient = smtpClient;
}
protected override async Task SendCoreAsync(string recipient, string message)
{
await _smtpClient.SendEmailAsync(recipient, "Notification", message);
}
}
public class SmsSender : NotificationSenderBase
{
private readonly ITwilioClient _twilioClient;
public SmsSender(ITwilioClient twilioClient, ILogger logger, IConfiguration config)
: base(logger, config)
{
_twilioClient = twilioClient;
}
protected override async Task SendCoreAsync(string recipient, string message)
{
await _twilioClient.SendSmsAsync(recipient, message);
}
}
Modern Interface Features
Default Interface Methods (C# 8+)
public interface ILogger
{
void Log(string message);
// Default implementation - optional to override
void LogError(string message) => Log($"ERROR: {message}");
void LogWarning(string message) => Log($"WARNING: {message}");
void LogInfo(string message) => Log($"INFO: {message}");
// Can call other members
void LogException(Exception ex) => LogError($"{ex.GetType().Name}: {ex.Message}");
}
public class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine(message);
// Uses default implementations for LogError, LogWarning, LogInfo
}
public class FileLogger : ILogger
{
private readonly string _path;
public FileLogger(string path) => _path = path;
public void Log(string message) => File.AppendAllText(_path, message + "\n");
// Override default implementation
public void LogError(string message)
{
Log($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] ERROR: {message}");
}
}
Static Abstract Members (C# 11+)
// Interface can require static members
public interface IParseable<TSelf> where TSelf : IParseable<TSelf>
{
static abstract TSelf Parse(string s);
static abstract bool TryParse(string s, out TSelf result);
}
public readonly struct Point : IParseable<Point>
{
public int X { get; }
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
// Implement static abstract members
public static Point Parse(string s)
{
var parts = s.Trim('(', ')').Split(',');
return new Point(int.Parse(parts[0]), int.Parse(parts[1]));
}
public static bool TryParse(string s, out Point result)
{
try
{
result = Parse(s);
return true;
}
catch
{
result = default;
return false;
}
}
}
// Generic method using static abstract
public static T ParseAny<T>(string input) where T : IParseable<T>
{
return T.Parse(input); // Calls static method on T
}
var point = ParseAny<Point>("(10, 20)");
Interface with Properties and Accessors
public interface IReadOnlyEntity
{
int Id { get; }
string Name { get; }
}
public interface IEntity : IReadOnlyEntity
{
new string Name { get; set; } // Adds setter
}
// Init-only properties in interfaces (C# 9+)
public interface IImmutableEntity
{
int Id { get; init; }
string Name { get; init; }
}
Multiple Interface Implementation
Implementing Multiple Interfaces
public interface ISerializable
{
byte[] Serialize();
}
public interface ICloneable<T>
{
T Clone();
}
public interface IComparable<T>
{
int CompareTo(T other);
}
public interface IEquatable<T>
{
bool Equals(T other);
}
// Class implements all relevant interfaces
public class Product : ISerializable, ICloneable<Product>, IComparable<Product>, IEquatable<Product>
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public byte[] Serialize()
{
return JsonSerializer.SerializeToUtf8Bytes(this);
}
public Product Clone()
{
return new Product { Id = Id, Name = Name, Price = Price };
}
public int CompareTo(Product other)
{
return Price.CompareTo(other?.Price ?? 0);
}
public bool Equals(Product other)
{
return Id == other?.Id;
}
}
Interface Composition
// Build up capabilities through interface composition
public interface IReadable
{
string Read();
}
public interface IWritable
{
void Write(string content);
}
public interface IReadWrite : IReadable, IWritable { }
public interface ISeekable
{
long Position { get; set; }
void Seek(long offset);
}
public interface IStream : IReadWrite, ISeekable, IDisposable { }
// Implement exactly what you need
public class MemoryStream : IStream
{
private byte[] _buffer;
private long _position;
public long Position
{
get => _position;
set => _position = value;
}
public string Read() => Encoding.UTF8.GetString(_buffer, (int)_position, _buffer.Length - (int)_position);
public void Write(string content) => _buffer = Encoding.UTF8.GetBytes(content);
public void Seek(long offset) => _position = offset;
public void Dispose() => _buffer = null;
}
public class ReadOnlyStream : IReadable
{
public string Read() => "data";
// Doesn't implement write capabilities
}
Explicit Interface Implementation
Use when you need to hide interface members or resolve naming conflicts.
Hiding Interface Members
public interface ICollection<T>
{
void Add(T item);
bool Remove(T item);
void Clear();
int Count { get; }
}
public class ReadOnlyCollection<T> : ICollection<T>
{
private readonly List<T> _items;
public ReadOnlyCollection(IEnumerable<T> items)
{
_items = items.ToList();
}
// Public members
public int Count => _items.Count;
// Hidden - only accessible through interface
void ICollection<T>.Add(T item) =>
throw new NotSupportedException("Collection is read-only");
bool ICollection<T>.Remove(T item) =>
throw new NotSupportedException("Collection is read-only");
void ICollection<T>.Clear() =>
throw new NotSupportedException("Collection is read-only");
}
// Usage
var collection = new ReadOnlyCollection<int>(new[] { 1, 2, 3 });
Console.WriteLine(collection.Count); // Works
// collection.Add(4); // ERROR: Add is hidden
// Must cast to interface to access
((ICollection<int>)collection).Add(4); // Throws NotSupportedException
Resolving Naming Conflicts
public interface IAmericanDateFormatter
{
string Format(DateTime date); // Returns MM/dd/yyyy
}
public interface IEuropeanDateFormatter
{
string Format(DateTime date); // Returns dd/MM/yyyy
}
public class DateFormatter : IAmericanDateFormatter, IEuropeanDateFormatter
{
// Explicit implementations for each interface
string IAmericanDateFormatter.Format(DateTime date) => date.ToString("MM/dd/yyyy");
string IEuropeanDateFormatter.Format(DateTime date) => date.ToString("dd/MM/yyyy");
// Public method with default behavior
public string Format(DateTime date, string culture = "en-US")
{
return culture switch
{
"en-US" => ((IAmericanDateFormatter)this).Format(date),
"en-GB" or "de-DE" => ((IEuropeanDateFormatter)this).Format(date),
_ => date.ToString("yyyy-MM-dd")
};
}
}
// Usage
var formatter = new DateFormatter();
var date = new DateTime(2024, 12, 25);
Console.WriteLine(formatter.Format(date)); // 12/25/2024 (default)
Console.WriteLine(((IAmericanDateFormatter)formatter).Format(date)); // 12/25/2024
Console.WriteLine(((IEuropeanDateFormatter)formatter).Format(date)); // 25/12/2024
Explicit Implementation Best Practices
// β
Use when:
// 1. Interface has large number of members, but only some are commonly used
// 2. Implementing multiple interfaces with same member signatures
// 3. Interface members don't make sense as part of class's public API
// β Avoid when:
// 1. Members are commonly used - users shouldn't need to cast
// 2. Makes code harder to discover and use
Interface Versioning
Problem: Adding Members Breaks Implementations
// Version 1
public interface IRepository<T>
{
T GetById(int id);
void Add(T entity);
}
// Version 2 - Adding new member breaks existing implementations!
public interface IRepository<T>
{
T GetById(int id);
void Add(T entity);
void AddRange(IEnumerable<T> entities); // Breaking change!
}
Solution 1: Default Interface Methods
public interface IRepository<T>
{
T GetById(int id);
void Add(T entity);
// Default implementation - doesn't break existing code
void AddRange(IEnumerable<T> entities)
{
foreach (var entity in entities)
Add(entity);
}
}
Solution 2: Interface Segregation
// Original interface stays unchanged
public interface IRepository<T>
{
T GetById(int id);
void Add(T entity);
}
// New capability in separate interface
public interface IBulkRepository<T>
{
void AddRange(IEnumerable<T> entities);
}
// New implementations can implement both
public class SqlRepository<T> : IRepository<T>, IBulkRepository<T>
{
public T GetById(int id) => /* ... */;
public void Add(T entity) => /* ... */;
public void AddRange(IEnumerable<T> entities) => /* ... */;
}
Solution 3: Versioned Interfaces
public interface IRepositoryV1<T>
{
T GetById(int id);
void Add(T entity);
}
public interface IRepositoryV2<T> : IRepositoryV1<T>
{
void AddRange(IEnumerable<T> entities);
}
// Legacy code uses V1, new code uses V2
Marker Interfaces and Alternatives
Traditional Marker Interface
// Marker interface - no members, just marks a type
public interface ISerializable { }
public class Document : ISerializable
{
public string Content { get; set; }
}
// Usage
public void Save(object obj)
{
if (obj is ISerializable)
{
// Serialize and save
}
}
Modern Alternative: Attributes
// Attribute-based approach
[AttributeUsage(AttributeTargets.Class)]
public class SerializableAttribute : Attribute { }
[Serializable]
public class Document
{
public string Content { get; set; }
}
// Usage with reflection
public void Save(object obj)
{
var type = obj.GetType();
if (type.GetCustomAttribute<SerializableAttribute>() != null)
{
// Serialize and save
}
}
When to Use Each
| Use Case | Marker Interface | Attribute |
|---|---|---|
| Compile-time checking | β | β |
| Generic constraints | β | β |
| Runtime metadata | β | β |
| Multiple markers | β οΈ Works but verbose | β |
| Configuration data | β | β |
// Marker interface enables compile-time constraint
public class Repository<T> where T : IEntity // Compile-time check
{
public void Add(T entity) { }
}
// Attribute - only runtime checking
[Table("Products")]
[Index("Name")]
public class Product
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(100)]
public string Name { get; set; }
}
Interview Questions
1. What is the difference between an interface and an abstract class?
Answer:
| Feature | Interface | Abstract Class |
|---|---|---|
| Multiple inheritance | Yes | No |
| Fields | No (except static in C# 11) | Yes |
| Constructors | No | Yes |
| Access modifiers | Public only (mostly) | Any |
| Default implementation | Yes (C# 8+) | Yes |
| State | No | Yes |
Use interface for: Defining contracts, multiple inheritance, unrelated types sharing behavior Use abstract class for: Sharing code, hierarchy with common state, template method pattern
2. Can you explain default interface methods? When would you use them?
Answer: Default interface methods (C# 8+) allow providing implementation in interfaces.
Use cases:
- Versioning: Add new members without breaking implementations
- Utility methods: Provide convenience overloads
- Trait-like patterns: Compose behaviors
public interface ILogger
{
void Log(string message);
// Default implementation
void LogError(string message) => Log($"ERROR: {message}");
}
Caution: Canβt access instance state (no fields), implementation in interface is a code smell if overused.
3. What is explicit interface implementation and when do you use it?
Answer: Explicit implementation means the member is only accessible through the interface type.
void IDisposable.Dispose() { } // Only via IDisposable cast
Use when:
- Hiding rarely-used members
- Resolving name conflicts between interfaces
- Implementing infrastructure interfaces (IDisposable) that shouldnβt pollute the public API
4. How do you handle versioning of interfaces?
Answer: Three main approaches:
- Default methods (C# 8+): Add with default implementation
- Interface segregation: Create new interface for new capability
- Versioned interfaces:
IRepositoryV2 : IRepositoryV1
Best practice: Design interfaces to be minimal and focused (ISP). Easier to add new interfaces than modify existing ones.
5. Can an abstract class implement an interface partially?
Answer: Yes! An abstract class can implement some interface members and leave others abstract.
public interface IRepository<T>
{
T GetById(int id);
void Add(T entity);
void Delete(int id);
}
public abstract class RepositoryBase<T> : IRepository<T>
{
// Concrete implementation
public void Delete(int id)
{
// Shared delete logic
}
// Abstract - derived class must implement
public abstract T GetById(int id);
public abstract void Add(T entity);
}
Key Takeaways
- Interfaces define contracts - What can be done, not how
- Abstract classes provide base implementation - Share code in hierarchies
- Default interface methods - Enable versioning without breaking changes
- Multiple interfaces > single abstract class - Better flexibility
- Explicit implementation - Hide or resolve conflicts
- Marker interfaces - Use attributes for metadata, interfaces for constraints