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:

  1. Existing code continues to work without any changes
  2. New capabilities can be gradually adopted by teams when they’re ready
  3. 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:

  1. Class implementation: If the class implements the method, it always wins
  2. Interface default: If no class implementation exists, use the interface default
  3. Compilation error: If no implementation exists anywhere
  4. 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:

FeatureAbstract ClassesDefault Interface Methods
Multiple InheritanceNo - single inheritance onlyYes - implement multiple interfaces
Instance FieldsFull supportNo - interfaces cannot have instance fields
ConstructorsYes - can define constructorsNo - interfaces cannot have constructors
VersioningAdding methods breaks inheritanceCan add default methods without breaking
PerformanceDirect method callsSlight overhead for interface dispatch
Use CaseBase implementation with shared stateContract 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:

ScenarioUse Default Interface Methods?Why
Adding methods to existing public interfacesYesPreserves backward compatibility with existing implementations
Convenience methods without instance stateYesPerfect for utility methods that work with the core API
Optional featuresYesImplementers can use defaults or customize as needed
Evolving public librariesYesAdds functionality without breaking consumers
Logic requiring instance fieldsNoInterfaces cannot contain state
Needs constructorsNoInterfaces cannot have constructors
Core functionalityNoBetter to make these explicit requirements
Performance-critical hot pathsNoVirtual 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