C# Interface Usage: Contracts, Polymorphism, and DI
c# interface usage: Learn how to use C# interfaces for contracts, polymorphism, and dependency injection with practical code examples and common pitfalls.
c# interface usage requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
C# interfaces define a contract that implementing types must satisfy. They are central to many design patterns, dependency injection, and unit testing. Understanding how to use interfaces effectively changes how you structure larger C# codebases.
Declaring an Interface
An interface in C# is declared with the interface keyword. It can contain method signatures, properties, events, and indexers, but no implementation until C# 8 introduced default interface methods. The default access level for interface members is public, and you cannot use access modifiers on members in an interface.
public interface ILogger { void Log(string message); bool IsEnabled { get; } }
This interface declares a Log method and a read-only property IsEnabled. Any class that implements ILogger must provide concrete implementations for these members. The interface itself carries no behavior; it only specifies the shape.
Implementing an Interface
A class implements an interface by listing it in its declaration and providing bodies for all interface members. A class can implement multiple interfaces, which is a key difference from class inheritance.
public class ConsoleLogger : ILogger { public bool IsEnabled => true; public void Log(string message) { Console.WriteLine($"{DateTime.UtcNow}: {message}"); } }
If a class fails to implement any member, the compiler raises an error. This compile-time enforcement is the primary benefit of interfaces: it guarantees that any ILogger reference can call Log and read IsEnabled without knowing the concrete type.
Using Interfaces for Polymorphism
Interfaces enable polymorphism because you can write code that operates on the interface type rather than a concrete class. This allows different implementations to be swapped at runtime without changing the calling code.
public class FileLogger : ILogger { private readonly string _path; public FileLogger(string path) { _path = path; } public bool IsEnabled => true; public void Log(string message) { File.AppendAllText(_path, message + Environment.NewLine); } } public class LogProcessor { private readonly ILogger _logger; public LogProcessor(ILogger logger) { _logger = logger; } public void Process(string data) { if (_logger.IsEnabled) { _logger.Log(data); } } }
LogProcessor depends only on ILogger. You can pass a ConsoleLogger or a FileLogger without modifying LogProcessor. This is the essence of interface-based polymorphism and is the foundation of many design patterns, including Strategy and Repository.
Interfaces and Dependency Injection
Dependency injection (DI) relies heavily on interfaces to decouple components. When you register a service in a DI container, you typically map an interface to a concrete implementation. At runtime, the container resolves the interface to the registered class.
public interface IOrderRepository { Order GetById(int id); void Save(Order order); } public class SqlOrderRepository : IOrderRepository { public Order GetById(int id) { /* ADO.NET or EF Core code */ } public void Save(Order order) { /* persistence logic */ } } // In composition root services.AddScoped<IOrderRepository, SqlOrderRepository>();
Consumers take IOrderRepository in their constructors. This makes the system testable: you can inject a fake or in-memory implementation during unit tests. Without interfaces, mocking frameworks would have to work against concrete classes, which is often brittle and requires the class to be virtual or non-sealed.
Default Interface Methods
C# 8 introduced default interface methods, allowing you to provide a body in an interface. This is useful when you want to add a new member to an interface without breaking existing implementers. The implementing class can choose to override it or use the default.
public interface ILogger { void Log(string message); bool IsEnabled { get; } void LogWarning(string message) { Log($"WARNING: {message}"); } }
Existing classes that implement ILogger do not need to change; they inherit the default LogWarning implementation. However, default interface methods have limitations. They are not available in .NET Framework and require the runtime to support them. They also introduce a subtle risk: if a class implements two interfaces with the same default method signature, you may need explicit implementation to resolve ambiguity.
Interface Segregation and Maintainability
The Interface Segregation Principle (ISP) states that no client should be forced to depend on methods it does not use. Large, bloated interfaces force implementers to write empty stubs or throw NotSupportedException. Splitting a fat interface into smaller, focused interfaces improves maintainability.
public interface IReadableRepository { Order GetById(int id); } public interface IWritableRepository { void Save(Order order); } public class OrderRepository : IReadableRepository, IWritableRepository { public Order GetById(int id) { /* ... */ } public void Save(Order order) { /* ... */ } }
A read-only consumer can depend on IReadableRepository and never see Save. This reduces coupling and makes the code easier to reason about. When you notice a class implementing many members it never uses, consider whether the interface is too broad.
Common Pitfalls and Runtime Considerations
Interface calls are slightly slower than direct virtual calls because they go through an interface dispatch table. In most applications this overhead is negligible, but in tight loops with millions of iterations it can matter. If profiling shows a bottleneck, you can use a concrete type or generic constraints to avoid the interface dispatch.
Another pitfall is explicit interface implementation. When a class implements an interface member explicitly, the member is only accessible through the interface reference, not the class reference. This is useful for hiding members, but it can confuse developers if overused.
public class HiddenLogger : ILogger { void ILogger.Log(string message) { /* ... */ } bool ILogger.IsEnabled => true; } var logger = new HiddenLogger(); // logger.Log("x"); // compile error ((ILogger)logger).Log("x"); // works
Finally, be careful with versioning. Adding a member to a widely used interface is a breaking change unless you provide a default implementation. Default interface methods mitigate this, but they require the consumer to target a runtime that supports them. Always document the minimum runtime version when using C# 8+ features.
Interfaces are a tool for defining contracts, not for sharing code. When you need shared implementation, consider abstract classes or composition. Using interfaces where they do not belong adds indirection without benefit. The key is to apply interfaces where you need polymorphism, testability, or a clean separation of concerns.