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.
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.
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.
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)IPaymentGateway — ships features without waiting on the payments team's release cycleNeither 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.
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:
IPaymentGateway should have ChargeAsync and RefundAsync — not a dozen Stripe-specific configuration methods nobody outside the payments team should call.CancellationToken parameter on every I/O-bound interface member means fakes and real implementations behave predictably under test timeouts and shutdown.StripeChargeResponse instead of a neutral PaymentResult ties every caller — and every test fake — to Stripe's shape, defeating the point of the abstraction.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.
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":
OrderService class, unmodified, runs against real infrastructure in production and fakes in tests.gateway.ChargeAsync(...) where gateway is typed IPaymentGateway looks up the concrete implementation via the object's interface map — conceptually similar to virtual dispatch (lesson 074), with its own lookup mechanicsLesson 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.
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.
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.
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.
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?
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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.