C# Interfaces in Depth: Default Methods, DI and Testing
You've used interfaces throughout Foundations without thinking too hard about them. It's time to think hard about them — because they're the single most important design tool in this entire module.
In Foundations, an interface probably looked like a formality: a contract with a name starting with I, implemented by one class, mostly there because a tutorial told you to. In a real codebase, interfaces do something much bigger — they're the seams along which a system can be tested, swapped, and owned by different teams without anyone stepping on anyone else's code:
// The order service's ENTIRE test suite runs in milliseconds, with zero
// network calls, zero database, zero real payment gateway — because
// every one of its dependencies is an interface, not a concrete class.
public sealed class OrderService(
IInventoryRepository inventory,
IPaymentGateway payments,
IOrderNotifier notifier)
{
public async Task<OrderResult> PlaceOrderAsync(Order order)
{
if (!await inventory.HasStockAsync(order.Items))
return OrderResult.OutOfStock();
var paymentResult = await payments.ChargeAsync(order.Total);
if (!paymentResult.Success)
return OrderResult.PaymentFailed(paymentResult.Reason);
await inventory.ReserveAsync(order.Items);
await notifier.NotifyOrderPlacedAsync(order);
return OrderResult.Success(order.Id);
}
}
// A test supplies FakeInventoryRepository, FakePaymentGateway, FakeOrderNotifier.
// Production supplies SqlInventoryRepository, StripeGateway, EmailOrderNotifier.
// OrderService's code is IDENTICAL in both cases — it only ever sees the interfaces.
That's the real reason interfaces matter: OrderService doesn't know or care whether it's running against a real database or a fake one, a real payment gateway or a fraud-testing stub. The interface is a seam — a place the system can be cut apart, tested in pieces, and reassembled with different concrete parts, at will.
In this lesson, you'll go deeper into interfaces: default interface methods (C# 8+), a full, honest interface-versus-abstract-class comparison, how to design interfaces specifically for testability and dependency injection, and how interface contracts function as boundaries between teams and modules — not just between classes.
A Quick Refresher, at Intermediate Depth
An interface declares a set of members — methods, properties, events, indexers — with no state and, traditionally, no implementation. Any class or struct can implement it by supplying that implementation, regardless of what else that class inherits from. You know this part. What's new at this depth is understanding why that "no state, implement from anywhere" design is so powerful, and what's changed about interfaces since C# 8.
Default Interface Methods (C# 8+)
Before C# 8, every interface member was purely abstract — every implementer had to write every method, even boilerplate that was identical everywhere. C# 8 introduced default interface methods: an interface member can now carry a body, which implementers inherit automatically unless they choose to override it.
public interface IAuditableEntity
{
DateTime CreatedAtUtc { get; }
DateTime? ModifiedAtUtc { get; }
// Default interface method — a real, callable implementation, right here
string DescribeAge()
{
var age = DateTime.UtcNow - CreatedAtUtc;
return age.TotalDays < 1
? "Created today"
: $"Created {(int)age.TotalDays} day(s) ago";
}
}
public sealed class Invoice(DateTime createdAtUtc) : IAuditableEntity
{
public DateTime CreatedAtUtc { get; } = createdAtUtc;
public DateTime? ModifiedAtUtc { get; private set; }
// DescribeAge() is NOT reimplemented — Invoice gets the default for free
}
IAuditableEntity invoice = new Invoice(DateTime.UtcNow.AddDays(-3));
Console.WriteLine(invoice.DescribeAge()); // "Created 3 day(s) ago"
This was added mainly to solve API evolution: a published interface used by thousands of consumers could never gain a new member without breaking every existing implementer — until now. Adding a new member with a default body doesn't break anyone; every existing implementation keeps compiling, and simply inherits the default until it chooses to override it.
invoice.DescribeAge() works when the variable's static type is IAuditableEntity, but if invoice were typed as Invoice and Invoice didn't declare DescribeAge() itself, it wouldn't appear on that variable at all. This is a real, frequently-surprising gotcha — covered further in lesson 080's explicit interface implementation.
Big Picture — Interfaces as Team Boundaries
In a real organization, interfaces aren't just a compiler feature — they're often the actual line where one team's ownership ends and another's begins.
Task<PaymentResult> ChargeAsync(decimal amount, string currency, string token)
- Depends only on
IPaymentGateway— ships features without waiting on the payments team's release cycle
- Free to change internals, swap providers, or add a new gateway entirely, as long as the interface's promise is kept
Neither team needs to read the other's source code to work together correctly — they only need to agree on, and honor, the interface. That's a much stronger and more scalable form of decoupling than "please don't break my code," because the compiler enforces it.
Designing Interfaces for Testability and DI
Not every interface is equally well-designed for testing and dependency injection. A few concrete habits separate an interface that makes testing trivial from one that quietly makes it painful:
- Model behavior your caller actually needs, not everything the implementation happens to expose.
IPaymentGatewayshould haveChargeAsyncandRefundAsync— not a dozen Stripe-specific configuration methods nobody outside the payments team should call. - Keep async signatures consistent and cancellable. A
CancellationTokenparameter on every I/O-bound interface member means fakes and real implementations behave predictably under test timeouts and shutdown. - Avoid leaking implementation-specific types into the signature. A method returning
StripeChargeResponseinstead of a neutralPaymentResultties every caller — and every test fake — to Stripe's shape, defeating the point of the abstraction. - Favor several small interfaces over one that does everything — a topic lesson 079 covers in full under the name Interface Segregation.
Simple Example — Testable by Construction
public interface IClock
{
DateTime UtcNow { get; }
}
public sealed class SystemClock : IClock
{
public DateTime UtcNow => DateTime.UtcNow;
}
public sealed class FakeClock(DateTime fixedTime) : IClock
{
public DateTime UtcNow => fixedTime;
}
public sealed class SubscriptionService(IClock clock)
{
public bool IsExpired(Subscription sub) => clock.UtcNow > sub.ExpiresAtUtc;
}
// Test — no waiting for real time to pass, no flaky date-dependent assertions
var clock = new FakeClock(new DateTime(2026, 1, 1));
var service = new SubscriptionService(clock);
var sub = new Subscription { ExpiresAtUtc = new DateTime(2025, 12, 31) };
Console.WriteLine(service.IsExpired(sub)); // True — deterministic, every time
Code → Meaning → Result: Wrapping something as unassuming as "the current time" behind an interface turns an untestable dependency (real wall-clock time, different every run) into a fully controllable one. This exact pattern — a one-member interface wrapping something the .NET runtime doesn't let you fake directly — shows up constantly in production code for time, randomness, file systems, and environment variables.
Real-World Example — Testing OrderService Without Any Real Infrastructure
public interface IInventoryRepository
{
Task<bool> HasStockAsync(IReadOnlyList<OrderItem> items, CancellationToken ct = default);
Task ReserveAsync(IReadOnlyList<OrderItem> items, CancellationToken ct = default);
}
// A hand-written test fake — no mocking framework required for something this small
public sealed class FakeInventoryRepository : IInventoryRepository
{
public bool StockAvailable { get; set; } = true;
public bool ReserveWasCalled { get; private set; }
public Task<bool> HasStockAsync(IReadOnlyList<OrderItem> items, CancellationToken ct = default) =>
Task.FromResult(StockAvailable);
public Task ReserveAsync(IReadOnlyList<OrderItem> items, CancellationToken ct = default)
{
ReserveWasCalled = true;
return Task.CompletedTask;
}
}
// Test — OrderService (from the hook) receives all three fakes, no real dependencies at all
var inventory = new FakeInventoryRepository { StockAvailable = false };
var service = new OrderService(inventory, new FakePaymentGateway(), new FakeOrderNotifier());
var result = await service.PlaceOrderAsync(SomeTestOrder());
Debug.Assert(result == OrderResult.OutOfStock());
Debug.Assert(!inventory.ReserveWasCalled); // correctly never reserved stock it didn't have
Why this matters beyond "tests run fast":
- This test asserts real business logic — "don't reserve stock you don't have" — without a database, network call, or payment sandbox anywhere in sight.
- The fake is a genuinely simple class, not a complex mocking-framework setup — because the interface itself is small and focused, per the testability guidance above.
- This same seam is what makes dependency injection (lesson 081) meaningful in production: the exact same
OrderServiceclass, unmodified, runs against real infrastructure in production and fakes in tests.
Under the Hood
- At the type level, the runtime tracks which of the class's methods satisfy which interface members — this mapping is built once, when the type is loaded
- Calling
gateway.ChargeAsync(...)wheregatewayis typedIPaymentGatewaylooks up the concrete implementation via the object's interface map — conceptually similar to virtual dispatch (lesson 074), with its own lookup mechanics
- If a class doesn't override a default interface method, the call resolves to the interface's own implementation — but, critically, only when the compile-time type of the reference is the interface, not the concrete class (see the callout above)
- An interface reference is just a reference to the underlying object plus type information for dispatch — there's no separate "interface object" allocated; it's the same object you'd get from any other reference to it
Abstract Class vs. Interface — the Full Comparison
Lesson 075 introduced this comparison from the abstract-class side. Here it is in full, now that you understand both tools at depth:
| Question | Abstract class | Interface |
|---|---|---|
| Can hold instance fields / state? | Yes | No |
| Can have a constructor? | Yes | No |
| How many can a class use at once? | Exactly one (single inheritance) | Any number |
| Can members have real implementation? | Yes, freely | Yes, via default interface methods — but only reachable through the interface type |
| Access modifiers on members? | Full range — public, protected, private | Effectively public API surface by default |
| What relationship does it express? | "IS-A" — a genuine, narrow kind-of relationship, with shared code | "CAN-DO" — a capability, independent of what else the type is |
| Best for... | A stable hierarchy with real shared implementation to write once | A contract implemented by otherwise-unrelated types; the seam for testing and DI |
| Team-boundary friendliness | Weaker — forces a shared base, coupling every implementer's inheritance chain | Strong — implementers need only agree on the contract, nothing about their own class hierarchy |
That last row is often the deciding factor in real systems: interfaces let two teams collaborate through a contract without either team needing to know or care what the other's classes inherit from. That's a freedom abstract classes, by their single-inheritance nature, simply can't offer.
Common Mistakes
Mistake 1 — Interfaces that leak implementation details
public interface IPaymentGateway
{
// Ties every implementer and every test fake to Stripe's own response shape
Task<StripeChargeResponse> ChargeAsync(decimal amount);
}
Define a neutral PaymentResult type owned by the abstraction itself, and have StripeGateway translate Stripe's response into it — the interface should never require its callers to understand any one implementation's data shape.
Mistake 2 — Assuming default interface methods behave like abstract class defaults
Invoice invoice = new Invoice(DateTime.UtcNow);
// invoice.DescribeAge(); // compiler error — DescribeAge() isn't visible
// on the Invoice type itself, only on IAuditableEntity
IAuditableEntity asInterface = invoice;
asInterface.DescribeAge(); // works — accessed through the interface type
Remember that a default interface method is only visible when the value is referenced through the interface type — an easy trap the first time you rely on one, and a key reason to understand explicit interface implementation (lesson 080), which relies on the very same mechanism deliberately.
Mistake 3 — One interface per concrete class, with no real abstraction behind it
Generating IOrderService for OrderService, ICustomerService for CustomerService, and so on, mechanically, purely because "everything should have an interface" — often with only one implementation ever, and members that mirror the concrete class exactly.
Introduce an interface when there's a genuine reason: multiple implementations, a testing seam, or a real team boundary (echoing lesson 073's Mistake 2) — not as a reflexive naming ritual applied to every class in the project.
When Should I Use It?
- A class has an external dependency (a database, an HTTP call, the system clock, the file system) that you'll want to replace with a fake or a test double.
- Two or more genuinely different implementations of the same behavior exist or are expected — different payment gateways, different notification channels, production versus test.
- You're defining a boundary between modules, layers, or teams, where the contract needs to be agreed upon and stable, independent of either side's internal class hierarchy.
Mental Model
Default interface method = a fallback implementation, reachable only through the interface itself.
Remember:
· An interface is a seam — the place a system can be cut apart for testing and swapped in production.
· Interfaces cost nothing in inheritance slots; abstract classes cost exactly one.
· A well-designed interface expresses a capability a caller needs, not everything an implementation happens to offer.
Key Takeaway
- Default interface methods (C# 8+) let an interface carry real implementation, mainly to allow published interfaces to grow without breaking existing implementers — but they're only reachable through the interface type.
- The full abstract-class-versus-interface comparison comes down to state and constructors (abstract classes), single vs. multiple use (interfaces win), and which relationship — "is-a" or "can-do" — actually fits.
- Interfaces designed for testability model what a caller needs, use neutral types, and stay small and focused — never leaking one implementation's shape into the contract.
- An interface is a seam: it's what lets a class be tested against fakes and run against real infrastructure in production, unmodified.
- Interfaces function as agreements between teams and modules — a stronger, compiler-enforced form of decoupling than "please don't break my code."
Check Your Understanding
Let's confirm you can reason about interfaces as production design tools, not just syntax.
1. Why were default interface methods added to C# 8?
Show answer
Correct: B
Why B is correct: The core motivation was API evolution — before C# 8, adding any member to a published interface broke every existing implementation. A default body means existing implementers keep compiling and simply inherit the new default until they choose to override it.
Why A is incorrect: Interfaces still cannot hold instance fields even with default interface methods — that remains one of the clearest lines between interfaces and abstract classes.
Why C is incorrect: Abstract classes remain useful specifically for state, constructors, and single-inheritance shared implementation — default interface methods narrow one gap but don't replace the tool.
Why D is incorrect: Interfaces, with or without default methods, still cannot be instantiated directly — you can only instantiate a concrete implementing type.
Reinforcement: Default interface methods solve a specific problem — breaking changes on interface growth — not a general substitute for abstract classes.
2. A class Invoice implements IAuditableEntity, which has a default interface method DescribeAge() that Invoice does not override. What happens when you write invoice.DescribeAge() where invoice is declared as type Invoice?
Show answer
Correct: B
Why B is correct: A default interface method is only reachable through the interface type. Since the variable is statically typed as Invoice, not IAuditableEntity, and Invoice doesn't declare DescribeAge() itself, the compiler reports it as not found — this is the exact gotcha called out in Mistake 2.
Why A is incorrect: This would be true if invoice were typed (or cast) as IAuditableEntity — but as a plain Invoice reference, the call doesn't compile at all.
Why C is incorrect: This is a compile-time visibility issue, not a runtime failure — the code never reaches execution in the first place.
Why D is incorrect: There's no silent failure here; the compiler stops the build before this could ever run.
Reinforcement: Always be explicit about which type — the interface or the concrete class — you're calling a default interface method through.
3. Two teams — Checkout and Payments — need to collaborate on payment processing without either blocking the other's release schedule. Which design best supports that goal, and why?
Show answer
Correct: B
Why B is correct: An interface contract is exactly the team-boundary tool this lesson describes — Checkout needs no knowledge of Payments' internal class hierarchy, and Payments can refactor freely as long as the interface's promise is honored, letting both teams ship independently.
Why A is incorrect: An abstract class would force Checkout's classes to inherit from a Payments-owned base, spending Checkout's single inheritance slot and coupling their class hierarchies together — exactly the weaker team-boundary property called out in this lesson's comparison table.
Why C is incorrect: This sidesteps the technical question entirely and isn't a software design answer — the interface contract is what enables two teams to stay separate while still collaborating safely.
Why D is incorrect: Depending on a concrete class directly couples Checkout to Stripe specifically and to Payments' internal implementation details, defeating the entire purpose of the abstraction.
Reinforcement: Interfaces let two teams change independently as long as the shared contract stays honored — that's their real value at organizational scale.
4. Which interface design most likely creates problems for both testability and future implementers?
Show answer
Correct: B
Why B is correct: Returning StripeChargeResponse leaks one specific implementation's data shape into the contract — every other implementer (PayPal, ACH, and every test fake) is forced to either depend on Stripe's types or produce awkward, meaningless values for fields that don't apply to them. This is exactly Mistake 1 from this lesson.
Why A is incorrect: This signature uses a neutral result type and an idiomatic cancellation token — a well-designed, implementation-agnostic member.
Why C is incorrect: This uses plain, neutral types (a bool and a general-purpose list) that any implementation or fake can satisfy easily.
Why D is incorrect: This is the well-designed IClock member from the Simple Example — small, neutral, and trivially fakeable.
Reinforcement: A good interface member's types belong to the abstraction, never to one specific implementation behind it.
You now see interfaces as design seams, not decoration — which sets up multiple interface implementation (078) and interface segregation (079) perfectly.