Introduction
C# is a powerful and ever-evolving programming language, loved by developers for its robustness and versatility. With each new version, it introduces features that enhance convenience and streamline development.
However, much like Peter Parker, developers must wield this power responsibly. One slip-up, and you might find yourself tangled in a web of bugs and confusion.
, introduced in C# 8.0. This feature allows developers to define methods with default implementations directly in interfaces. While it enables smoother API evolution by allowing methods to be added without breaking existing implementations and improves C#'s interoperation with platforms like Android (Java) and iOS (Swift), it also opens the door to subtle, hard-to-detect bugs, especially when combined with inheritance and dependency injection.
In this article, we’ll explore a seemingly straightforward scenario where Default Interface Implementations swing into action, only to deliver a surprise ending. This topic was inspired by a recent situation I encountered in my own practice, which highlighted how easily things can go awry. We’ll unravel the technical reasons behind this unexpected behavior and share tips to ensure your code doesn’t fall into the same trap.
A Simple Service
Let’s start with a basic example. Imagine you’re working on a service, MyService, that depends on an IFoo interface. Nothing fancy, just your standard DI setup:
public class MyService
{
private readonly IFoo _foo;
// Constructor with an injected service.
public MyService(IFoo foo)
{
_foo = foo;
}
// A method that prints a value.
public void PrintValue()
{
// Get the value.
var value = _foo.GetValue();
// Print the value.
Console.WriteLine(value);
}
}
The IFoo interface has a single method, GetValue, which includes a default implementation. Here’s how the interface and its implementation look:
public interface IFoo
{
// A method declaration with a default implementation.
string GetValue() => "IFoo";
}
// An implementation of the interface.
public class Foo : IFoo
{
// Overriding the method.
public string GetValue() => "Foo";
}
When you run this in your trusty unit test, everything works as expected:
[TestFixture]
public class FooTests
{
[Test]
public void GetValueMustReturnFoo()
{
// Create an instance of the service.
var foo = new Foo();
// Get the value.
var value = foo.GetValue();
// Ensure the value is correct and equal to "Foo".
Assert.AreEqual("Foo", value);
}
}
The test passes, confirming that Foo.GetValue() returns "Foo".
Finally, you use MyService in a console app. In reality, this is a common scenario where the service would typically be resolved from a Dependency Injection (DI) container, but for simplicity, let's use the console app to demonstrate the concept.
class Program
{
static void Main(string[] args)
{
// Create an instance of `Foo`.
var foo = new Foo();
// Create an instance of `MyService` using the `Foo` instance.
var service = new MyService(foo);
// Execute the method to get an output.
service.PrintValue();
}
}
Running this program produces the expected output:
Foo
All is right in the world. Or so you think...
What Could Go Wrong?
Now, imagine someone on your team decides to refactor the Foo class by introducing a base class FooBase:
// Introduce a base class.
public class FooBase : IFoo
{
}
// Derive `Foo` from the base class.
public class Foo : FooBase
{
// No other changes here.
public string GetValue() => "Foo";
}
Seems harmless enough, right? Right?
You run the tests. They pass. You deploy to production, and then… SURPRISE! The output changes:
IFoo
.
Explicitly Derive from the Interface: Even if you introduce a base class that implements an interface, the derived class should explicitly declare that it implements the interface itself and provide method overrides as necessary.
// Explicitly implement the interface
// Yes, even if the class is derived from `FooBase`, which implements `IFoo`.
public class Foo : FooBase, IFoo
{
// Override the method.
public string GetValue() => "Foo";
}
Test Through Interfaces: Modify tests to interact with the interface directly, rather than through a concrete class orvar. This ensures that the behavior being tested aligns with the contract defined by the interface. Usingvaror directly referencing the concrete class can inadvertently bypass interface behavior, masking potential issues with default implementations or inheritance.
// The improved test: Interact through the interface.
[Test]
public void GetValueMustReturnFoo()
{
// Create an instance of the service using the interface, not `var`.
IFoo foo = new Foo();
// Get the value.
var value = foo.GetValue();
// Ensure the value is correct and equal to "Foo".
Assert.AreEqual("Foo", value);
}
Leverage Static Analysis Tools: Use tools like Roslyn analyzers (e.g. ) to catch risky patterns.
Document and Review Changes: Always document changes involving default methods and review them thoroughly.
Conclusion
Default Interface Implementations in C# are a double-edged sword. While they provide immense flexibility and streamline API evolution, they also introduce complexities that can catch even experienced developers off guard. As shown in this example, subtle changes in class hierarchies can lead to unexpected behaviors, often surfacing only in production.
To avoid such pitfalls, prioritize composition over inheritance to reduce unintended coupling and improve code maintainability. Always test through interfaces to ensure that your implementations behave correctly, independent of specific class hierarchies. Additionally, document changes meticulously to keep the team aligned and prevent surprises.
Default Interface Implementations may be powerful, but with a thoughtful approach, you can navigate their pitfalls and ensure that inheritance doesn’t troll you again.
SOCIAL SHARE CARD GENERATOR