Interfaces as contracts in C# 14: members allowed, default implementations, static abstract members for generic math, explicit implementation, dispatch cost and struct boxing.
An interface is a contract: a set of members a type promises to provide, with no instance state. A class or struct can implement many interfaces. Since C# 8 an interface can carry default implementations (used only through the interface, and they need .NET Core 3.0+), plus static, private and protected members. Since C# 11 it can declare static abstract / static virtual members, which power generic math (INumber<T>, T.Zero). Explicit implementation (void IDisposable.Dispose()) hides a member unless you cast. Calls through an interface use interface dispatch, which the JIT can often devirtualise. Casting a struct to an interface boxes it; a generic constraint (where T : IComparable<T>) avoids the box.
An interface is a contract: a set of members that implementing types promise to provide. Modern C# interfaces can contain much more than abstract method signatures — default implementations, static members and static abstract members — so I'd cover what they can contain today, explicit implementation, how interface calls are dispatched at run time, and the boxing trap with structs.
| Member | Since | Notes |
|---|---|---|
| Abstract methods, properties, indexers, events | C# 1 | The classic contract |
| Default interface methods (with bodies) | C# 8 | Implementers inherit them unless they provide their own |
| Static methods, fields, constructors, operators | C# 8 | Utility members on the interface itself |
Private / protected / internal members, sealed members |
C# 8 | Helpers for default implementations |
static abstract / static virtual members |
C# 11 | Implementers must provide static members; used via generics (T.Parse, operators) |
| No instance fields or instance constructors | — | Interfaces still can't hold instance state |
public interface INotifier
{
Task SendAsync(Notification n, CancellationToken ct);
Task SendManyAsync(IEnumerable<Notification> items, CancellationToken ct) =>
Task.WhenAll(items.Select(n => SendAsync(n, ct))); // default implementation
}
SendManyAsync and existing implementations still compile.((INotifier)x).SendManyAsync(...)), unless the class implements them.public interface IHasDefault<TSelf> where TSelf : IHasDefault<TSelf>
{
static abstract TSelf Default { get; }
}
public static T OrDefault<T>(T? value) where T : struct, IHasDefault<T> => value ?? T.Default;
This lets generic code call static members of the type argument. It's the foundation of .NET 7+ generic math (INumber<T>, IParsable<T>, IAdditionOperators<,,>), allowing T Sum<T>(...) where T : INumber<T> with + on T. Calls are resolved per type at JIT time — no virtual dispatch.
public sealed class AuditLog : IEnumerable<AuditEntry>, IDisposable
{
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); // explicit: only via interface
public IEnumerator<AuditEntry> GetEnumerator() => _entries.GetEnumerator();
void IDisposable.Dispose() { /* ... */ } // hides Dispose from the public API
}
Use explicit implementation to:
IEnumerable.GetEnumerator),ICollection<T>.IsReadOnly).Explicit members are only accessible through an interface reference.
A class can implement many interfaces, so an interface method doesn't have a fixed vtable slot across types. CoreCLR uses virtual stub dispatch: each call site starts with a lookup, then caches the target for the type it saw (monomorphic stub: "if MethodTable == X, jump to X's method"), falling back to a hash-based resolver when many types appear. The JIT can devirtualise interface calls when the concrete type is known (sealed types, new in the same method, value types in generic code, or guarded devirtualisation from dynamic PGO).
public readonly struct Money : IComparable<Money> { ... }
IComparable<Money> c = money; // BOXES - allocates
Sort(List<IComparable<Money>>) // every element boxed
static int Compare<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b); // NO boxing
Converting a struct to an interface type boxes it (heap allocation, copy). Calls through the interface operate on the boxed copy — mutations don't affect the original. Generic methods constrained to the interface avoid both problems: the JIT specialises code for the struct and calls the method directly.
A pricing library exposed decimal Apply(IDiscountRule rule, decimal price) and implemented rules as small structs for performance. Profiling showed millions of boxes per minute — every call boxed the struct to IDiscountRule. Changing the API to Apply<TRule>(TRule rule, decimal price) where TRule : IDiscountRule removed the allocations and let the JIT inline the rules. The struct design only paid off once the interface call path was generic.
Checks whether the candidate knows interfaces beyond the C# 7 definition.
Green flags: lists what interfaces can contain now; knows defaults are invoked only through the interface and why they exist; can explain static abstract and generic math; mentions explicit implementation; knows the struct boxing trap and the generic-constraint fix; mentions Interface Segregation.
Red flags: "interfaces cannot have any implementation"; "an interface is just an abstract class with no fields"; an interface for every class with no reason; no awareness of boxing.
Each class's MethodTable has an interface map listing implemented interfaces. For each interface, the runtime knows which class method implements each interface method (by name and signature, or explicit .override in IL for explicit implementations). Casting to an interface (is IFoo) checks the map (with caching).
call site --> indirection cell --> [lookup stub] first call: resolve & patch
--> [dispatch stub] monomorphic: cmp MT, jmp target
--> [resolve stub] polymorphic: hash table lookup
Recent .NET versions have worked on alternatives (e.g. cached interface dispatch as used in NativeAOT), but the principle — per-call-site caching — is the same.
AmbiguousImplementationException at run time for binary changes).this as the interface type.In IL, static abstract members are declared on the interface, and implementing types provide them. In generic code, calls use the constrained. prefix with a call to the static member, resolved per instantiation. For value types (the common case for generic math), the JIT generates specialised code and inlines operators — Sum<int> compiles to the same loop as a hand-written int sum (and may be vectorised).
The curiously recurring pattern where TSelf : IFoo<TSelf> lets interfaces refer to the implementing type for return types and operators.
IFooV2 : IFoo), or capability interfaces checked at run time (if (stream is IAsyncDisposable ad)).Interfaces: multiple inheritance of contracts, no state, work with structs, easy to mock. Abstract classes: shared state and constructors, protected members, template methods, single inheritance. Often both: an interface for the contract plus an optional abstract base class for convenience (e.g. IHostedService and BackgroundService).
INumber<T>-based Sum<T> and inspect the JIT disassembly for int.[MemoryDiagnoser].public interface IPaymentGateway
{
Task<ChargeResult> ChargeAsync(Money amount, CancellationToken ct);
Task RefundAsync(string chargeId, CancellationToken ct);
// Added in v2 without breaking existing implementers
Task<PaymentStatus> GetStatusAsync(string chargeId, CancellationToken ct)
=> throw new NotSupportedException($"{GetType().Name} does not report status");
}
// Explicit implementation resolves a name clash
public interface IFileLog { void Write(string m); }
public interface IAudit { void Write(string m); }
public sealed class AuditFileLog : IFileLog, IAudit
{
void IFileLog.Write(string m) => File.AppendAllText("app.log", m);
void IAudit.Write(string m) => File.AppendAllText("audit.log", m);
}
// Static abstract members: one method for every numeric type
public static T Average<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
T sum = T.Zero;
foreach (var v in values) sum += v;
return sum / T.CreateChecked(values.Length);
}
// Constrained generic avoids boxing structs
public static T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
What happens: methods take IComparable, IDisposable or custom interfaces and receive structs.
Why it is wrong: boxing on every call and mutations applied to a copy.
Do instead: use generic methods constrained to the interface.
What happens: shared behaviour is put into interface defaults to avoid an abstract class. Why it is wrong: no state, awkward to call from implementations, and confusing resolution rules. Do instead: use DIMs for interface evolution; use abstract classes or composition for shared implementation.
What happens: myNotifier.SendManyAsync(...) doesn't compile.
Why it is wrong: default members are only accessible through the interface type.
Do instead: cast to the interface or implement the member on the class.
What happens: one interface with 20 methods for different clients. Why it is wrong: implementers must provide irrelevant members; clients depend on what they don't use. Do instead: split into role interfaces (Interface Segregation).
What happens: adding abstract members to a published interface in a library. Why it is wrong: every implementer breaks. Do instead: use default implementations or a new interface.
What happens: main API members are explicitly implemented and invisible on the class. Why it is wrong: confusing for callers, who must cast. Do instead: use explicit implementation for clashes, non-generic legacy members, or rarely used members.
What happens: IFoo for every Foo without a real need.
Why it is wrong: indirection and maintenance cost.
Do instead: add interfaces at boundaries and real variation points.
What happens: tight loops call interface methods on mixed types. Why it is wrong: prevents inlining and can hit polymorphic stubs. Do instead: use sealed concrete types, generics, or batch APIs in hot paths.
A senior engineer treats an interface as a published contract whose change cost depends on who implements it.
| Situation | Interface choice |
|---|---|
| Boundary to I/O, time, a third party | Small interface owned by the consumer (IClock, IPaymentGateway) |
| Many implementations, no shared state | Interface |
| Shared state plus shared algorithm | Abstract base class, possibly also implementing an interface |
| Library interface implemented by others | Add members with default bodies; never change existing signatures |
| Math or parsing over many types | static abstract members with a self-referencing generic constraint |
| Hot path over value types | Generic constraint, not interface-typed parameters |
They also know the BCL guidance: interfaces are hard to evolve, so the framework itself often prefers abstract classes (Stream, DbConnection) for extensibility points that will grow. Default interface methods softened this but did not remove it, because they cannot use state.
An abstract class is a base class that cannot be instantiated and may declare abstract members with no body, which every non-abstract derived class must override. Unlike an interface it can hold instance fields, constructors and fully implemented members, so it is the place for shared state and a shared algorithm. The classic use is the template method pattern: a public non-virtual method defines the steps and calls protected abstract or protected virtual hooks. Constructors should be protected. A class can derive from only one abstract class. Adding a new virtual member with a body is non-breaking, but adding a new abstract member breaks every derived class.
A value type holds its data directly, so assigning or passing it copies the data; a reference type variable holds a reference to an object on the managed heap, so copying the variable makes two references to the same object. Value types are stored inline where they are declared: a local usually sits on the stack or in a register, but a struct field of a class lives inside that object on the heap, and captured or boxed values also end up on the heap. By default classes compare by identity and structs by their fields. I use structs for small, immutable values like Money or coordinates, and classes for everything with identity or behaviour.
Polymorphism means one interface, many forms: code written against a base type or interface works with any implementation, and each object behaves according to its actual type. Run-time (subtype) polymorphism uses virtual/override and interfaces; the call is dispatched through the object's method table (a vtable slot for virtual methods, an interface dispatch stub for interfaces), so shape.Area() runs Circle.Area or Square.Area. Compile-time polymorphism is chosen by the compiler: method overloading, generics (and static abstract interface members for generic maths). Pattern matching on types (switch on shape) is the alternative: good for closed sets of data types and operations added often; virtual methods are better when new types are added often.
How to tell it (STAR)
**Situation:** I owned a payments library whose `IPaymentGateway` interface was implemented by [three] partner teams. **Task:** add status polling without forcing everyone to change at once. **Action:** I used a C# 8 default interface method that throws `NotSupportedException`, plus a `SupportsStatus` flag defaulting to false, so the host only polls gateways that opt in. I added the public API analyser so future interface changes are visible in review. **Result:** the release shipped with no broken builds, and all partners adopted the new member within [two] releases.
Last updated 30 Oct 2026.