Interfaces in C
A deep-dive walkthrough of interfaces in C# — covering what a contract actually guarantees, how interfaces enable abstraction and loose coupling in practice, explicit interface implementation, default interface methods, generic and covariant/contravariant interfaces, common design patterns built on interfaces (Strategy, Repository, Dependency Injection), and the trade-offs that separate a well-designed interface from an over-engineered one.
Table of Contents
- Introduction
- What an Interface Actually Is
- Declaring and Implementing an Interface
- Abstraction: The Contract Hides the "How"
- Loose Coupling: Programming Against the Interface, Not the Implementation
- Multiple Interface Implementation
- Explicit Interface Implementation
- Default Interface Methods (C# 8+)
- Generic Interfaces
- Variance:
inandouton Generic Interfaces - Interfaces and Dependency Injection
- Common Design Patterns Built on Interfaces
- Interface Segregation: When a Contract Is Too Big
- Interfaces vs. Abstract Classes, Revisited
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
An interface in C# is a contract: a named set of members — methods, properties, events, indexers — that any implementing class or struct promises to provide, without the interface itself dictating how any of it is implemented. That single idea, applied consistently, is what makes interfaces the primary mechanism C# offers for two closely related but distinct goals: abstraction (hiding implementation detail behind a stable, simple surface) and loose coupling (letting code depend on a capability rather than a concrete type, so implementations can be swapped, mocked, or extended without touching the code that consumes them). This guide walks through the language mechanics in depth, then covers the design patterns and trade-offs that determine whether an interface is actually earning its place in a codebase.
interface IPaymentProcessor → the CONTRACT: "anything claiming to be a payment
bool ProcessPayment(decimal) processor can process a payment and returns whether it worked"
class StripeProcessor : IPaymentProcessor → ONE implementation of that contract
class PayPalProcessor : IPaymentProcessor → ANOTHER, entirely independent implementation
void Checkout(IPaymentProcessor processor) → calling code depends on the CONTRACT,
never on which implementation it got
1. What an Interface Actually Is
A pure contract — no state, and (traditionally) no implementation
public interface IShape
{
double GetArea();
double GetPerimeter();
}
An interface declares what members must exist and what signature they must have — it says nothing about how GetArea() computes anything, and it cannot hold instance fields or a constructor. You can never write new IShape() directly; an interface exists purely to be implemented by something else, which is the whole point — it's a specification, not a thing in itself.
An interface member has no access modifier — it's implicitly public
public interface IShape
{
double GetArea(); // implicitly public — you cannot write "private double GetArea();" here
}
Every member declared directly in an interface is public by default and cannot be marked otherwise (with narrow exceptions for private helper methods backing default implementations, covered in Section 7) — this follows directly from what an interface is for: a contract that's entirely about what's visible to the outside world, so there's no such thing as a "private" part of a public contract.
What "contract" means concretely: a compiler-enforced promise
public class Circle : IShape
{
public double Radius { get; set; }
public double GetArea() => Math.PI * Radius * Radius;
// ❌ Omitting GetPerimeter() here is a COMPILE ERROR — the contract isn't optional
}
If Circle claims to implement IShape but doesn't provide every member the interface declares, the code simply doesn't compile — this is what elevates "contract" from a comment or a naming convention into something the compiler actively enforces, which is a meaningfully stronger guarantee than, say, a dynamically-typed language's duck typing can offer.
2. Declaring and Implementing an Interface
Naming convention: the I prefix
public interface ILogger { void Log(string message); }
public interface IRepository<T> { T GetById(int id); }
C# convention (not a compiler requirement) prefixes interface names with I — ILogger, IRepository, IDisposable. This is purely a readability convention, but it's followed so consistently across the .NET ecosystem and virtually every C# codebase that deviating from it is itself a minor code smell worth avoiding without a specific reason.
Implementing an interface: the : syntax, shared with inheritance
public class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine(message);
}
C# uses the same : syntax for both class inheritance and interface implementation — class ConsoleLogger : ILogger reads identically to how you'd inherit from a base class, and as Section 5 covers, you can combine one base class with any number of interfaces in that same list.
A class can implement an interface implicitly through inherited members
public class BaseLogger
{
public void Log(string message) => Console.WriteLine(message);
}
public class FileLogger : BaseLogger, ILogger
{
// no Log() method here at all — BaseLogger's Log(string) already satisfies ILogger's contract
}
The compiler doesn't require the implementing member to be declared directly in the class claiming to implement the interface — if a base class (or, as Section 7 covers, a default interface method) already provides a matching member, that's sufficient to satisfy the contract, which is a subtle but useful piece of flexibility once class hierarchies and interfaces start combining.
3. Abstraction: The Contract Hides the "How"
Calling code interacts only with what the interface exposes
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
public class SendGridEmailSender : IEmailSender
{
private readonly HttpClient _http;
private readonly string _apiKey;
public async Task SendAsync(string to, string subject, string body)
{
// SendGrid-specific HTTP request construction, auth headers, retry logic — all HIDDEN
var request = BuildSendGridRequest(to, subject, body);
await _http.SendAsync(request);
}
}
Code calling SendAsync(to, subject, body) has no visibility into, and no dependency on, how the email actually gets sent — the HTTP client, the API key, the specific request format are all implementation detail entirely contained within SendGridEmailSender. This is abstraction working exactly as intended: the contract (IEmailSender) is the only thing the rest of the codebase needs to understand.
Why this matters beyond tidiness: implementations can change without rippling outward
// Swapping SendGrid for AWS SES means writing ONE new class:
public class SesEmailSender : IEmailSender
{
public async Task SendAsync(string to, string subject, string body) { /* AWS SES-specific logic */ }
}
// every existing caller of IEmailSender.SendAsync(...) needs ZERO changes
This is the concrete, practical payoff of abstraction: a vendor migration, a library upgrade, or a rewrite of the internal implementation is contained entirely within the implementing class, as long as the contract itself doesn't change — none of the (potentially many) call sites depending on IEmailSender need to be found, reviewed, or touched.
4. Loose Coupling: Programming Against the Interface, Not the Implementation
Tight coupling: the problem interfaces solve
// ❌ OrderService is tightly coupled to ONE concrete class
public class OrderService
{
private readonly SendGridEmailSender _emailSender = new(); // hardcoded, concrete dependency
public void PlaceOrder(Order order)
{
// ...
_emailSender.SendAsync(order.CustomerEmail, "Order confirmed", "...");
}
}
OrderService here can never be tested without genuinely sending an email through SendGrid, and can never use a different email provider without editing OrderService itself — the class's own logic and its dependency's concrete implementation are welded together.
Loose coupling via interface-typed dependencies
public class OrderService
{
private readonly IEmailSender _emailSender; // depends on the CONTRACT, not a concrete class
public OrderService(IEmailSender emailSender) => _emailSender = emailSender; // supplied from outside
public void PlaceOrder(Order order)
{
// ...
_emailSender.SendAsync(order.CustomerEmail, "Order confirmed", "...");
}
}
OrderService now knows nothing about SendGrid, AWS SES, or any other concrete email provider — it only knows it has something that can SendAsync. The concrete choice is made entirely outside this class (Section 10 covers exactly how, via dependency injection), which is the essence of loose coupling: the dependency is a detail resolved elsewhere, not a fact baked into the dependent class.
The concrete payoff: substitutability for testing
public class FakeEmailSender : IEmailSender
{
public List<string> SentEmails { get; } = new();
public Task SendAsync(string to, string subject, string body)
{
SentEmails.Add($"{to}: {subject}");
return Task.CompletedTask;
}
}
// In a unit test:
var fakeSender = new FakeEmailSender();
var service = new OrderService(fakeSender);
service.PlaceOrder(someOrder);
Assert.Single(fakeSender.SentEmails); // verify behavior WITHOUT a real email ever being sent
This is where loose coupling stops being an abstract virtue and becomes directly, practically useful: OrderService can be tested completely in isolation, with no network calls, no SendGrid account, and no flaky external dependency — because it depends on IEmailSender, any object satisfying that contract, including a purpose-built test double, works exactly as well as the real thing from OrderService's point of view.
5. Multiple Interface Implementation
A class can implement any number of interfaces, unlike single class inheritance
public interface IFlyable { void Fly(); }
public interface ISwimmable { void Swim(); }
public interface IWalkable { void Walk(); }
public class Duck : IFlyable, ISwimmable, IWalkable
{
public void Fly() => Console.WriteLine("Duck flying");
public void Swim() => Console.WriteLine("Duck swimming");
public void Walk() => Console.WriteLine("Duck walking");
}
C# allows only one base class but unlimited interfaces — this is frequently the deciding factor when choosing between an abstract class and an interface: if a type genuinely needs to satisfy several independent, unrelated contracts simultaneously, interfaces are the only mechanism that allows it without contorting a single-inheritance hierarchy to fit.
Combining a base class with multiple interfaces
public class Bird { public string Species { get; set; } }
public class Duck : Bird, IFlyable, ISwimmable
{
public void Fly() => Console.WriteLine("Duck flying");
public void Swim() => Console.WriteLine("Duck swimming");
}
The base class, if present, always comes first in the list, followed by any interfaces — Duck inherits Species from Bird (genuine shared state and identity, the "is-a" relationship) while separately implementing IFlyable and ISwimmable (capabilities, the "can-do" relationships) — a natural combination of inheritance and interface implementation working together rather than competing.
6. Explicit Interface Implementation
The problem: two interfaces demanding a member with the same name
public interface IEnglishSpeaker { void Greet(); }
public interface ISpanishSpeaker { void Greet(); }
A class implementing both IEnglishSpeaker and ISpanishSpeaker can't just write one Greet() method — a single implicit implementation would ambiguously satisfy both contracts with the same behavior, which usually isn't what's actually wanted.
Explicit implementation: qualifying the member with the interface name
public class BilingualPerson : IEnglishSpeaker, ISpanishSpeaker
{
void IEnglishSpeaker.Greet() => Console.WriteLine("Hello!");
void ISpanishSpeaker.Greet() => Console.WriteLine("¡Hola!");
}
var person = new BilingualPerson();
// person.Greet(); // ❌ won't compile — explicit implementations aren't accessible directly on the class
IEnglishSpeaker english = person;
english.Greet(); // "Hello!" — accessible only through the specific interface reference
ISpanishSpeaker spanish = person;
spanish.Greet(); // "¡Hola!"
An explicit interface implementation is only accessible when the object is referenced through that specific interface type — this resolves the naming collision above, and as a side effect, is also a useful technique for deliberately hiding a member from a class's "main," everyday public surface while still making it available to code that specifically works with the interface (a common pattern for members that are technically necessary but shouldn't clutter the typical usage of a class).
7. Default Interface Methods (C# 8+)
The problem: adding a member to an interface used to break every implementer
Before C# 8: adding a single new method to a widely-implemented interface
was a BREAKING CHANGE — every class implementing that interface, anywhere
in the codebase or in downstream consumers of a published library, would
fail to compile until it added an implementation for the new member.
For a small, internal codebase this was an annoyance; for a widely-published library interface with many external implementers, it was often close to impossible to evolve without a major version bump that broke everyone.
Default implementations let an interface evolve without breaking existing implementers
public interface ILogger
{
void Log(string message);
// New in v2 of this interface — existing implementers get this for FREE, no compile error
void LogError(string message) => Log($"ERROR: {message}");
}
public class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine(message);
// LogError is not implemented here — it inherits the DEFAULT implementation above, automatically
}
ConsoleLogger compiles and works correctly even though it never wrote a LogError method — the interface itself supplies a default, and any implementer is free to override it if it needs different behavior. This is a genuinely useful escape hatch for interface evolution, but it's worth using deliberately: it blurs the classical "interfaces are pure contracts with zero implementation" rule, and overusing default methods can turn an interface into something closer to an abstract class in disguise, without abstract classes' more explicit intent.
Explicitly overriding a default implementation
public class FileLogger : ILogger
{
public void Log(string message) => WriteToFile(message);
public void LogError(string message) => WriteToFile($"[ERROR] {message}"); // overrides the default
}
Any implementer remains free to supply its own version of a member that has a default implementation — the default only applies when the implementer doesn't provide one, exactly the same "optional override" relationship an abstract class's virtual methods have with their subclasses.
8. Generic Interfaces
Why a non-generic repository interface forces awkward casting
// ❌ Without generics, a shared repository contract can't express WHAT it's a repository OF
public interface IRepository
{
object GetById(int id); // caller has to cast the result — no compile-time type safety
}
A non-generic interface here loses type information entirely — every caller has to cast the returned object back to whatever type they actually expected, which reintroduces exactly the kind of unsafe, error-prone code interfaces are meant to help eliminate.
A generic interface preserves type safety across many different implementations
public interface IRepository<T>
{
T GetById(int id);
IEnumerable<T> GetAll();
void Add(T entity);
}
public class UserRepository : IRepository<User>
{
public User GetById(int id) { /* ... */ return new User(); }
public IEnumerable<User> GetAll() { /* ... */ return new List<User>(); }
public void Add(User entity) { /* ... */ }
}
public class ProductRepository : IRepository<Product>
{
public Product GetById(int id) { /* ... */ return new Product(); }
public IEnumerable<Product> GetAll() { /* ... */ return new List<Product>(); }
public void Add(Product entity) { /* ... */ }
}
IRepository<T> defines the contract once, generically, and each concrete repository fills in its specific entity type — UserRepository.GetById(1) returns a strongly-typed User, no cast required, while still sharing exactly one contract definition across every entity type the application has, which is the combination of Section 5's reuse benefit and genuine compile-time type safety.
Constraining a generic interface's type parameter
public interface IRepository<T> where T : class, IEntity
{
T GetById(int id);
}
public interface IEntity { int Id { get; } }
A where constraint restricts what T is allowed to be — here, T must be a reference type that implements IEntity, which lets the interface (or its implementers) rely on every T having an Id property, without needing to fall back to reflection or unsafe assumptions about what T actually is.
9. Variance: in and out on Generic Interfaces
The problem variance solves: why IRepository<Dog> isn't automatically an IRepository<Animal>
public class Animal { }
public class Dog : Animal { }
IRepository<Dog> dogRepo = new DogRepository();
// IRepository<Animal> animalRepo = dogRepo; // ❌ does NOT compile by default — generic interfaces
// are invariant unless explicitly marked otherwise
Even though Dog is an Animal, IRepository<Dog> is not automatically treated as an IRepository<Animal> — this looks surprising at first, but it's actually protecting against a real type-safety hole: if IRepository<T> had an Add(T item) method, allowing this assignment would let code add a Cat through the IRepository<Animal> reference into what's actually a DogRepository underneath.
Covariance (out): safe when the type parameter is only ever returned, never accepted
public interface IReadOnlyRepository<out T> // "out" — T only appears in OUTPUT positions
{
T GetById(int id); // returning T is fine
// void Add(T item); // would NOT be allowed to compile here — T can't be an input parameter
}
IReadOnlyRepository<Dog> dogRepo = new DogReadOnlyRepository();
IReadOnlyRepository<Animal> animalRepo = dogRepo; // ✅ this compiles — covariance permits it
Marking T as out tells the compiler this interface only ever produces T values, never consumes them — which makes it provably safe to treat an IReadOnlyRepository<Dog> as an IReadOnlyRepository<Animal>, since every Dog returned genuinely is a valid Animal. IEnumerable<T> in .NET is a familiar real-world example of exactly this pattern.
Contravariance (in): safe when the type parameter is only ever accepted, never returned
public interface IAnimalHandler<in T> // "in" — T only appears in INPUT positions
{
void Handle(T item); // accepting T is fine
// T GetLast(); // would NOT be allowed to compile here — T can't be a return type
}
IAnimalHandler<Animal> animalHandler = new GenericAnimalHandler();
IAnimalHandler<Dog> dogHandler = animalHandler; // ✅ this compiles — contravariance permits it
The reverse situation: if an interface only ever consumes T, it's safe to treat a handler of the more general type (Animal) as if it were a handler of a more specific type (Dog) — a handler that knows how to deal with any Animal can certainly deal with the Dogs it's given. IComparer<T> is a familiar real-world .NET example of contravariance in practice.
10. Interfaces and Dependency Injection
Constructor injection: the standard way loose coupling becomes real, running code
public class OrderService
{
private readonly IEmailSender _emailSender;
private readonly IRepository<Order> _orderRepository;
public OrderService(IEmailSender emailSender, IRepository<Order> orderRepository)
{
_emailSender = emailSender;
_orderRepository = orderRepository;
}
}
Section 4 established the principle; this is the standard mechanism for actually wiring it up — OrderService declares what it needs, in terms of interfaces, and something external is responsible for supplying concrete implementations. This is what makes the loose coupling genuinely load-bearing rather than theoretical.
The DI container: resolving interfaces to concrete implementations, centrally
// Typical ASP.NET Core setup — registering WHICH concrete class satisfies each interface, in ONE place
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IEmailSender, SendGridEmailSender>();
builder.Services.AddScoped<IRepository<Order>, SqlOrderRepository>();
builder.Services.AddScoped<OrderService>();
var app = builder.Build();
// Anywhere OrderService is requested, the container automatically supplies a SendGridEmailSender
// and a SqlOrderRepository into its constructor — no calling code constructs these manually
A dependency injection container centralizes the decision of "which concrete class satisfies this interface" into one place (typically application startup), rather than scattering new SendGridEmailSender() calls throughout the codebase — swapping SendGridEmailSender for SesEmailSender becomes a one-line change in this registration, with zero changes needed anywhere else, which is the fullest practical expression of the loose coupling Section 4 introduced.
11. Common Design Patterns Built on Interfaces
Strategy pattern: swapping an algorithm at runtime
public interface IDiscountStrategy { decimal Apply(decimal price); }
public class NoDiscount : IDiscountStrategy { public decimal Apply(decimal price) => price; }
public class PercentageDiscount : IDiscountStrategy { public decimal Apply(decimal price) => price * 0.9m; }
public class Checkout
{
private readonly IDiscountStrategy _discountStrategy;
public Checkout(IDiscountStrategy strategy) => _discountStrategy = strategy;
public decimal GetTotal(decimal price) => _discountStrategy.Apply(price);
}
The Strategy pattern is really just Section 4's loose coupling applied specifically to interchangeable algorithms — Checkout doesn't know or care which discount logic it's running, only that it has some IDiscountStrategy, and a new discount type is added by writing one new class, never by modifying Checkout itself (directly echoing the Open/Closed Principle).
Repository pattern: abstracting data access behind a contract
public interface IOrderRepository
{
Order GetById(int id);
void Add(Order order);
}
// A service depending on IOrderRepository doesn't know or care whether orders
// live in SQL Server, a document database, or an in-memory test double
The Repository pattern (touched on in Section 8's generic form) is one of the most common real-world uses of interfaces specifically for abstraction — it draws a clean boundary between business logic and data-access technology, letting the two evolve, get tested, and get replaced independently.
Observer pattern: interfaces as a callback contract
public interface IOrderObserver { void OnOrderPlaced(Order order); }
public class OrderPlacedEmailNotifier : IOrderObserver
{
public void OnOrderPlaced(Order order) => Console.WriteLine($"Emailing about order {order.Id}");
}
public class OrderService
{
private readonly List<IOrderObserver> _observers = new();
public void Subscribe(IOrderObserver observer) => _observers.Add(observer);
public void PlaceOrder(Order order)
{
// ... place the order ...
foreach (var observer in _observers) observer.OnOrderPlaced(order);
}
}
Here an interface defines a callback contract — any number of unrelated observer classes can subscribe to OrderService without OrderService needing to know anything about what each observer actually does, which is a direct, practical extension of loose coupling into a one-to-many notification scenario (closely related to .NET's own built-in event/delegate mechanism, which solves a similar problem with different syntax).
12. Interface Segregation: When a Contract Is Too Big
A fat interface forces implementers to support things they don't need
// ❌ Every implementer of IWorker must support ALL THREE, even if irrelevant to it
public interface IWorker
{
void Work();
void Eat();
void Sleep();
}
public class RobotWorker : IWorker
{
public void Work() { /* ... */ }
public void Eat() => throw new NotSupportedException(); // a robot doesn't eat
public void Sleep() => throw new NotSupportedException(); // or sleep
}
A NotSupportedException inside an interface implementation is a strong, reliable signal that the interface's contract doesn't actually match what every implementer can honestly promise — RobotWorker is being forced to lie about supporting Eat() and Sleep() just to satisfy the compiler.
Splitting into focused, single-purpose interfaces
public interface IWorkable { void Work(); }
public interface IFeedable { void Eat(); }
public interface IRestable { void Sleep(); }
public class RobotWorker : IWorkable { public void Work() { /* ... */ } }
public class HumanWorker : IWorkable, IFeedable, IRestable
{
public void Work() { /* ... */ }
public void Eat() { /* ... */ }
public void Sleep() { /* ... */ }
}
Each interface now represents exactly one capability — a RobotWorker implements only what it can honestly support, while HumanWorker (thanks to Section 5's multiple-interface support) implements all three, genuinely. This is the Interface Segregation Principle in direct practice: clients (and implementers) should never be forced to depend on members they don't actually use or can't honestly provide.
13. Interfaces vs. Abstract Classes, Revisited
The core distinguishing question: is there genuinely shared state or behavior?
Interface: models a "CAN-DO" capability — use when types need to support
the same operation without sharing meaningful implementation or a
genuine common ancestry (Duck and Airplane can both Fly(), sharing nothing else).
Abstract class: models an "IS-A" relationship WITH real shared state/behavior —
use when subclasses genuinely share non-trivial implementation, not
just a method signature (CheckingAccount and SavingsAccount both
genuinely share Balance and Deposit logic).
This is the same distinction covered in depth elsewhere, restated here because it's the single most common real design question interfaces raise in practice — reaching for an abstract class purely to get code reuse when the types involved don't share a genuine "is-a" relationship creates the same kind of false, brittle hierarchy that overusing inheritance generally does.
A quick decision checklist
Do multiple, otherwise-UNRELATED types need this capability? → interface
Does the class need to inherit from something else too? → interface (single inheritance rule)
Is there real, non-trivial SHARED implementation to reuse? → abstract class (or composition, per
the composition-over-inheritance principle)
Do you need to add a member later without breaking implementers? → interface WITH a default method (Section 7)
Worth treating as a starting heuristic rather than an absolute rule — real designs sometimes reasonably combine both, as Section 5's Bird/IFlyable/ISwimmable example does, using an abstract or concrete base class for genuine shared state and one or more interfaces layered on top for the additional, independent capabilities.
14. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
| Depending on concrete classes instead of interfaces | Hard to unit test in isolation; swapping an implementation later requires touching every call site | Depend on interfaces (Section 4); inject concrete implementations via the constructor (Section 10) |
| Fat interfaces mixing unrelated capabilities | Implementers are forced to support members irrelevant to them, often via NotSupportedException
|
Split into small, focused interfaces per Interface Segregation (Section 12) |
Reaching for new object construction of a concrete type deep inside a class |
Recreates the tight coupling interfaces exist to prevent, even if the class's public surface looks decoupled | Accept dependencies as interface-typed constructor parameters, resolved externally (Section 10) |
| Overusing default interface methods (C# 8+) | Blurs the line between "pure contract" and "partial implementation," making an interface behave like an abstract class without the clearer intent | Use default methods specifically for backward-compatible evolution, not as a general implementation-sharing mechanism |
| Forcing an abstract class hierarchy purely to get polymorphism when types share no real implementation | Creates an artificial "is-a" relationship and unnecessary coupling between unrelated types | Use an interface instead — it costs nothing to implement when there's no shared state to inherit |
| Ignoring variance and hitting confusing compile errors on generic interface assignments |
IRepository<Dog> isn't assignable to IRepository<Animal> by default, which looks like a compiler bug until variance is understood |
Mark the type parameter out (covariant) or in (contravariant) when the interface's usage pattern genuinely supports it (Section 9) |
| Explicit interface implementation used to "hide" members without understanding the access rules | Confusion when a member seemingly "disappears" from a class's public API, since it's only reachable through the specific interface reference | Use explicit implementation deliberately for name collisions (Section 6) or intentional API surface control, and document why |
| Skipping interfaces entirely on "internal, never going to change" components | The moment that assumption turns out wrong (a new test double is needed, a second implementation appears), a retrofit touches every call site | Default to interface-typed dependencies for anything crossing a meaningful boundary (data access, external services), even if only one implementation exists today |
Quick Reference Table
| Concept | C# Syntax | Purpose |
|---|---|---|
| Basic contract | interface IShape { double GetArea(); } |
Declares required members with no implementation |
| Implementation | class Circle : IShape { ... } |
Compiler-enforced promise to provide every declared member |
| Multiple interfaces | class Duck : Bird, IFlyable, ISwimmable |
Satisfies several independent capability contracts at once |
| Explicit implementation | void IEnglishSpeaker.Greet() { ... } |
Resolves naming collisions; scopes a member to its specific interface reference |
| Default interface method |
void LogError(string m) => Log(m); inside the interface |
Lets an interface gain new members without breaking existing implementers |
| Generic interface | interface IRepository<T> { T GetById(int id); } |
One contract definition reused with full type safety across many entity types |
| Covariance | interface IReadOnlyRepository<out T> |
Safely widens a "producer-only" generic interface to a more general type |
| Contravariance | interface IAnimalHandler<in T> |
Safely narrows a "consumer-only" generic interface to a more specific type |
| Constructor injection | public OrderService(IEmailSender sender) |
Supplies a concrete implementation from outside, achieving real loose coupling |
Conclusion
An interface's entire value comes from being nothing more than a contract — no state, no forced implementation, just a promise the compiler enforces — and everything this guide covers ultimately traces back to what that enables: abstraction, because calling code only ever needs to understand the contract, never the implementation behind it; and loose coupling, because a class depending on an interface can have its actual dependency swapped, mocked, or extended without ever being touched itself. The language features layered on top of that core idea — multiple implementation, explicit implementation, default methods, generics, and variance — all exist to make that basic contract more expressive and more able to evolve safely over time, not to complicate the underlying idea.
The recurring design judgment call this guide keeps returning to is knowing when an interface is earning its place: a contract genuinely shared by unrelated types, a dependency that should be swappable or testable, a boundary between business logic and technical detail. Used well, per Section 11's patterns, interfaces are what let a C# codebase stay flexible as it grows; used reflexively — as a fat, catch-all contract, or as a substitute for genuinely shared implementation an abstract class or composition would model more honestly — they add ceremony without the loose coupling ever actually paying off.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the swap-one-provider-for-another-with-zero-call-site-changes moment that made loose coupling click far better than any definition ever did.
Top comments (0)