TL;DR:
- Default Interface Methods (DIM) allow method implementations directly in interfaces (C# 8+).
- Solve the versioning problem: Add new methods to interfaces without breaking existing implementations.
- Use for optional, reusable behavior: classes can use the defaults or override them.
- No instance fields or constructors in interfaces, use abstract classes if you need state.
- Multiple default implementations? You must resolve conflicts explicitly (diamond problem).
- Performance overhead is minor compared to abstract classes, but usually not a concern.
- Keep defaults meaningful and simple to avoid violating SOLID principles.
- Best suited for library authors and API maintainers needing backward-compatible evolution.
Default Interface Methods in C# solve a common problem that every library maintainer faces: how do you add new functionality to an interface without breaking existing implementations? Introduced in C# 8, this feature lets you add default implementations directly in interfaces, making API changes safer and easier.
If you’ve ever hesitated to add a method to a public interface because it would break every class that implements it, default interface methods fix that problem. They bridge the gap between interfaces and abstract classes, giving you the extensibility of interfaces with the convenience of shared implementation.
What Are Default Interface Methods?
Default Interface Methods (DIM) let you define method implementations directly inside interfaces. Unlike traditional interface methods that only contain signatures, default methods include actual code that implementing classes can use as-is or override.
Here’s a basic example that demonstrates the concept:
public interface ILogger
{
void Log(string message);
// Default implementation that any class can use or override
void LogWithTimestamp(string message)
{
Log($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}] {message}");
}
// Another default method for error logging
void LogError(string message, Exception? exception = null)
{
Log($"ERROR: {message}{(exception != null ? $" - {exception.Message}" : "")}");
}
}
public class ConsoleLogger : ILogger
{
// Only need to implement the core method
public void Log(string message) => Console.WriteLine(message);
// Default methods are inherited automatically
}
public class FileLogger : ILogger
{
private readonly string _filePath;
public FileLogger(string filePath) => _filePath = filePath;
public void Log(string message) => File.AppendAllText(_filePath, message + Environment.NewLine);
// Override the default when custom behavior is needed
public void LogWithTimestamp(string message)
{
Log($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}] {message}");
}
}
Notice how ConsoleLogger
gets the default implementations automatically, while FileLogger
overrides one method for its own custom behavior. That’s what makes default interface methods so useful, they give you good defaults but let you customize them when you need to.
Real-World Example: Evolving a Payment Interface
Let’s look at a more practical scenario focused on API evolution. Imagine you’re maintaining a payment processing library used by multiple teams. Your original interface looks like this:
public interface IPaymentProcessor
{
Task<PaymentResult> ProcessPaymentAsync(decimal amount, string currency);
Task<bool> RefundPaymentAsync(string transactionId);
}
Later, you need to add support for payment options and metadata. With traditional interfaces, adding these methods would break every existing implementation. Default interface methods solve this elegantly:
public interface IPaymentProcessor
{
// Original contract remains unchanged
Task<PaymentResult> ProcessPaymentAsync(decimal amount, string currency);
Task<bool> RefundPaymentAsync(string transactionId);
// New version-safe method with default implementation
async Task<PaymentResult> ProcessPaymentWithOptionsAsync(
decimal amount,
string currency,
PaymentOptions? options = null)
{
// By default, delegate to the original method that all implementations have
return await ProcessPaymentAsync(amount, currency);
}
// Additional functionality without breaking changes
Task<PaymentResult> GetPaymentStatusAsync(string transactionId)
{
// Default implementation for backward compatibility
return Task.FromResult(new PaymentResult {
Success = true,
TransactionId = transactionId,
Status = "Unknown" // Limited functionality but doesn't break existing code
});
}
}
// Existing implementations continue to work unchanged
public class StripePaymentProcessor : IPaymentProcessor
{
public async Task<PaymentResult> ProcessPaymentAsync(decimal amount, string currency)
{
// Original implementation unchanged
return await CallStripeApiAsync(amount, currency);
}
public async Task<bool> RefundPaymentAsync(string transactionId)
{
// Original implementation unchanged
return await CallStripeRefundAsync(transactionId);
}
// No need to implement the new methods - they just work!
private async Task<PaymentResult> CallStripeApiAsync(decimal amount, string currency) { /* ... */ }
private async Task<bool> CallStripeRefundAsync(string transactionId) { /* ... */ }
}
This approach demonstrates the core value of default interface methods: safe interface evolution. Notice how:
- Existing code continues to work without any changes
- New capabilities can be gradually adopted by teams when they’re ready
- The original contract is preserved while enabling new features
In my experience, this is the sweet spot for default interface methods. When maintaining NuGet packages used by dozens of teams, DIMs allowed us to ship new API capabilities without forcing disruptive updates. Teams could opt-in to the new features at their own pace.
The key insight is that you’re not just adding methods, you’re providing evolutionary paths that enable gradual adoption rather than big-bang migrations.
Production-Ready Example: Building an Evolvable HTTP Client Interface
Here’s how DIMs help evolve a shared HTTP client interface without breaking existing implementations:
// Original interface used across many services
public interface IHttpClientWrapper
{
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default);
}
// Enhanced with backward compatibility using DIMs
public interface IHttpClientWrapper
{
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default);
// Added retry feature - no breaking changes!
async Task<HttpResponseMessage> SendWithRetryAsync(
HttpRequestMessage request,
int maxRetries = 3,
CancellationToken cancellationToken = default)
{
// Simple retry logic - implementations inherit this automatically
for (int i = 0; i < maxRetries; i++)
{
try { return await SendAsync(request, cancellationToken); }
catch when (i < maxRetries - 1) { await Task.Delay(1000); }
}
return await SendAsync(request, cancellationToken);
}
// Could also add logging, caching, or other cross-cutting concerns as DIMs
}
Teams override only what they need:
public class CustomClient : IHttpClientWrapper
{
private readonly HttpClient _client;
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken = default) => _client.SendAsync(request, cancellationToken);
// SendWithRetryAsync inherited automatically - no implementation needed!
}
Why this matters: DIMs enable API evolution without breaking changes or forcing base classes. Teams get functionality for free while maintaining flexibility.
Method Resolution and Call Hierarchy
flowchart TD A[Interface Definition] B[Class Implementation] C[Default Method in Interface] D[Class Override] A -->|Implements| B A --> C C -->|Used if No Override| B D -->|Overrides Default| C
Understanding Default Interface Method Resolution
Knowing how C# resolves default interface method calls helps you avoid unexpected behavior. Here’s how the runtime decides which method to call. The resolution follows this priority:
- Class implementation: If the class implements the method, it always wins
- Interface default: If no class implementation exists, use the interface default
- Compilation error: If no implementation exists anywhere
- Diamond problem resolution: If multiple interfaces provide the same default method, you must resolve the ambiguity explicitly
Here’s code that demonstrates these scenarios:
public interface INotificationSender
{
void SendNotification(string message);
// Default implementation
void SendUrgentNotification(string message)
{
Console.WriteLine($"URGENT: {message}");
SendNotification(message);
}
}
public interface IEmailSender
{
void SendEmail(string to, string subject, string body);
// Same method signature as INotificationSender - this creates ambiguity
void SendUrgentNotification(string message)
{
Console.WriteLine($"URGENT EMAIL: {message}");
SendEmail("admin@company.com", "Urgent Alert", message);
}
}
public class NotificationService : INotificationSender, IEmailSender
{
public void SendNotification(string message) => Console.WriteLine($"Notification: {message}");
public void SendEmail(string to, string subject, string body) =>
Console.WriteLine($"Email to {to}: {subject} - {body}");
// Must resolve the diamond problem explicitly
public void SendUrgentNotification(string message)
{
// Choose which interface's default to use, or provide custom logic
((INotificationSender)this).SendUrgentNotification(message);
// Or call both
// ((IEmailSender)this).SendUrgentNotification(message);
}
}
Default Interface Methods vs Abstract Classes
When should you use default interface methods instead of abstract classes? Here’s a quick comparison:
Feature | Abstract Classes | Default Interface Methods |
---|---|---|
Multiple Inheritance | No - single inheritance only | Yes - implement multiple interfaces |
Instance Fields | Full support | No - interfaces cannot have instance fields |
Constructors | Yes - can define constructors | No - interfaces cannot have constructors |
Versioning | Adding methods breaks inheritance | Can add default methods without breaking |
Performance | Direct method calls | Slight overhead for interface dispatch |
Use Case | Base implementation with shared state | Contract with optional convenience methods |
Here’s when to choose each approach:
// ABSTRACT CLASS: Ideal when you need shared state, fields, and constructors
public abstract class DatabaseRepository<T>
{
// Fields - not possible in interfaces
protected readonly DbContext _context;
// Constructor - not possible in interfaces
protected DatabaseRepository(DbContext context)
{
_context = context;
}
// Abstract method requiring implementation
public abstract Task<T> GetByIdAsync(int id);
// Implementation that uses instance fields
public virtual async Task<bool> ExistsAsync(int id) =>
await _context.Set<T>().AnyAsync(e => EF.Property<int>(e, "Id") == id);
}
// DEFAULT INTERFACE METHODS: Ideal for contracts with optional utility methods
public interface ICacheService
{
// Core API - required by all implementations
Task<T?> GetAsync<T>(string key);
Task SetAsync<T>(string key, T value, TimeSpan? expiry = null);
Task RemoveAsync(string key);
// Convenience method added via DIM - no breaking changes
async Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiry = null)
{
var cached = await GetAsync<T>(key);
if (cached != null) return cached;
var value = await factory();
await SetAsync(key, value, expiry);
return value;
}
// With DIMs, existing implementations continue to work without changes
// Without DIMs, we'd need extension methods or base classes instead
}
Common Pitfalls and How to Avoid Them
Ambiguous Method Resolution (The Diamond Problem)
The most common issue occurs when multiple interfaces define the same default method, known as the “diamond problem” in multiple inheritance:
// The example from the Method Resolution section demonstrates this issue.
// When a class implements both INotificationSender and IEmailSender,
// both containing a SendUrgentNotification method, the compiler requires
// explicit disambiguation.
public class NotificationService : INotificationSender, IEmailSender
{
// You must implement the conflicting method to resolve the ambiguity
public void SendUrgentNotification(string message)
{
// Choose one implementation or provide custom logic
((INotificationSender)this).SendUrgentNotification(message);
}
}
Solution: Use explicit interface implementation or provide a custom implementation that delegates to the appropriate interface.
Performance Considerations
Default interface methods incur a small performance overhead compared to abstract classes due to the interface dispatch mechanism.
For most business applications, this overhead is completely negligible. However, in performance-critical scenarios like high-volume data processing or real-time systems, you might want to consider abstract classes instead.
When to worry about performance:
- Method is called millions of times in tight loops
- You’re working on a performance-sensitive component
- Method itself does very minimal work (making the dispatch overhead proportionally significant)
Rule of thumb: Only optimize after measuring actual performance in your specific scenario. Profile both approaches in realistic conditions before making a decision based on performance.
Production Best Practices
Version-Safe API Evolution
Default interface methods excel at evolving APIs without breaking changes:
// Version 1.0 - Original interface
public interface IDataService
{
Task<T> GetAsync<T>(int id);
Task SaveAsync<T>(T entity);
}
// Version 2.0 - Add caching without breaking existing code
public interface IDataService
{
Task<T> GetAsync<T>(int id);
Task SaveAsync<T>(T entity);
// New methods with sensible defaults
async Task<T> GetWithCacheAsync<T>(int id, TimeSpan? cacheDuration = null)
{
// Default implementation without caching
return await GetAsync<T>(id);
}
async Task SaveWithValidationAsync<T>(T entity) where T : IValidatable
{
if (!entity.IsValid())
{
throw new ValidationException("Entity validation failed");
}
await SaveAsync(entity);
}
}
When to Use Default Interface Methods
Here’s a quick reference table to help you decide when default interface methods are appropriate:
Scenario | Use Default Interface Methods? | Why |
---|---|---|
Adding methods to existing public interfaces | Yes | Preserves backward compatibility with existing implementations |
Convenience methods without instance state | Yes | Perfect for utility methods that work with the core API |
Optional features | Yes | Implementers can use defaults or customize as needed |
Evolving public libraries | Yes | Adds functionality without breaking consumers |
Logic requiring instance fields | No | Interfaces cannot contain state |
Needs constructors | No | Interfaces cannot have constructors |
Core functionality | No | Better to make these explicit requirements |
Performance-critical hot paths | No | Virtual dispatch has slight overhead compared to direct calls |
Key Takeaways
Default Interface Methods in C# make it easier to update APIs and reuse code. They solve the versioning problem that’s been a pain point for interface design for years.
Use them to add functionality to existing interfaces without breaking implementations, provide convenient utility methods, and create better APIs. Just remember to keep the logic simple, avoid violating SOLID principles, and be aware of the slight performance overhead in high-throughput scenarios.
The best part? Your existing code continues to work unchanged while gaining access to new functionality automatically. That’s the kind of backward compatibility that makes maintenance easier and teams happier.
Related Posts
- C# Abstract Class vs Interface: 10 Real-World Questions You Should Ask
- Prefer Interfaces Over Abstract Classes in C#: Build Flexible, Testable, and Maintainable Code
- How Polymorphism Makes C# Code Flexible: Real-World Examples and Best Practices
- Interface in C#: Contracts, Decoupling, Dependency Injection, Real-World Examples, and Best Practices
- Delegates vs Events in C#: Explained with Examples and Best Practices