C# · lesson 13 of 26
Interfaces in C#
C# interfaces by example: implementing contracts, segregation, explicit implementation, default interface methods, and swapping implementations for tests.
By SkillByExample · Updated
Defining and Implementing Interfaces
An interface is a contract: a list of members an implementing type must provide. Code written against the interface works with every implementation.
IShape[] shapes = { new Circle(5), new Rectangle(4, 6) };
foreach (IShape shape in shapes)
Console.WriteLine($"{shape.Name}: area = {shape.Area():F2}");
Console.WriteLine(shapes[0] is IShape);
Console.WriteLine(typeof(Circle).GetInterfaces()[0].Name);
public interface IShape
{
double Area();
string Name { get; }
}
public class Circle(double radius) : IShape
{
public double Area() => Math.PI * radius * radius;
public string Name => "Circle";
}
public class Rectangle(double w, double h) : IShape
{
public double Area() => w * h;
public string Name => "Rectangle";
}
$ dotnet run
Circle: area = 78.54
Rectangle: area = 24.00
True
IShape
Circle and Rectangle share no base class, only a promise. The loop never checks which type it holds; it calls the interface members and each object answers for itself. Leave out Name from either class and the build fails with CS0535.
Small Interfaces, Composed
Split a fat interface into small ones so each class implements only what it can actually do (the Interface Segregation Principle).
var repo = new InMemoryRepo();
repo.Add(new Customer(1, "Ada"));
repo.Add(new Customer(2, "Alan"));
Report(repo);
Console.WriteLine(repo is IWriteRepository<Customer>);
Console.WriteLine(repo is ISearchable<Customer>);
static void Report(IReadRepository<Customer> reader) =>
Console.WriteLine($"{reader.GetAll().Count()} customers, #2 = {reader.GetById(2)?.Name}");
public record Customer(int Id, string Name);
public interface IReadRepository<T>
{
T? GetById(int id);
IEnumerable<T> GetAll();
}
public interface IWriteRepository<T>
{
void Add(T entity);
}
public interface ISearchable<T> : IReadRepository<T>
{
IEnumerable<T> Search(string query);
}
public class InMemoryRepo : IReadRepository<Customer>, IWriteRepository<Customer>
{
private readonly Dictionary<int, Customer> _store = new();
public Customer? GetById(int id) => _store.GetValueOrDefault(id);
public IEnumerable<Customer> GetAll() => _store.Values;
public void Add(Customer c) => _store[c.Id] = c;
}
$ dotnet run
2 customers, #2 = Alan
True
False
Report asks only for IReadRepository<Customer>, so it cannot accidentally write. InMemoryRepo did not sign up for search, and the is check says so — nothing forced it to stub out a Search it cannot support.
Multiple Interfaces and IDisposable
A class has one base class but can implement any number of interfaces. IDisposable is the one you meet most often, because using calls it.
using (var p = new OrderProcessor())
{
p.Log("start");
Console.WriteLine(p.Validate(0, out var error) ? "ok" : error);
Console.WriteLine(p.Validate(3, out _) ? "ok" : "bad");
}
Console.WriteLine("after using");
public interface ILogger { void Log(string message); }
public interface IValidator<T> { bool Validate(T value, out string? error); }
public class OrderProcessor : ILogger, IValidator<int>, IDisposable
{
public void Log(string message) => Console.WriteLine($"log: {message}");
public bool Validate(int lineCount, out string? error)
{
error = lineCount == 0 ? "Order must have at least one line" : null;
return error is null;
}
public void Dispose() => Console.WriteLine("disposed");
}
$ dotnet run
log: start
Order must have at least one line
ok
disposed
after using
Dispose runs when the using block ends — before “after using” — even if an exception had been thrown inside the block. That guarantee is why resources such as file handles and connections implement IDisposable.
Explicit Interface Implementation
When two interfaces declare a member with the same name but different meanings, implement each one explicitly. An explicit member is reachable only through that interface.
var d = new Distance(10);
Console.WriteLine(d.Meters);
Console.WriteLine(((IMetric)d).Value);
Console.WriteLine(((IImperial)d).Value);
IImperial imp = d;
Console.WriteLine(imp.Value.ToString("F1"));
public interface IMetric { double Value { get; } }
public interface IImperial { double Value { get; } }
public class Distance(double meters) : IMetric, IImperial
{
double IMetric.Value => meters;
double IImperial.Value => meters * 3.28084;
public double Meters => meters;
}
$ dotnet run
10
10
32.8084
32.8
The same object reports 10 or 32.8084 depending on which interface you look at it through. d.Value does not exist on Distance at all — the compiler would report CS1061 — so a caller must choose a unit explicitly.
Default Interface Methods (C# 8+)
An interface member can carry a default body, so adding it does not break existing implementers.
INotifier email = new EmailNotifier();
email.SendBatch(new[] { "a@x.com", "b@x.com" }, "Hello!");
Console.WriteLine(email.SenderName);
INotifier sms = new SmsNotifier();
sms.SendBatch(new[] { "555-0101", "555-0102" }, "Hi");
public interface INotifier
{
void Send(string recipient, string message);
void SendBatch(IEnumerable<string> recipients, string message)
{
foreach (var r in recipients) Send(r, message);
}
string SenderName => "System";
}
public class EmailNotifier : INotifier
{
public void Send(string to, string msg) => Console.WriteLine($"Email to {to}: {msg}");
}
public class SmsNotifier : INotifier
{
public void Send(string to, string msg) => Console.WriteLine($"SMS to {to}: {msg}");
public void SendBatch(IEnumerable<string> to, string msg) =>
Console.WriteLine($"SMS bulk to {string.Join(",", to)}: {msg}");
}
$ dotnet run
Email to a@x.com: Hello!
Email to b@x.com: Hello!
System
SMS bulk to 555-0101,555-0102: Hi
EmailNotifier never wrote SendBatch or SenderName and still gets them. SmsNotifier supplies its own SendBatch, which replaces the default. Note the variables are typed INotifier: a default method belongs to the interface, so new EmailNotifier().SendBatch(...) would not compile.
Interfaces for Swappable Dependencies
Depend on an interface rather than a concrete class, and a test can hand in a fake that never touches the network.
var controller = new WeatherReport(new FakeWeatherService());
Console.WriteLine(await controller.SummaryAsync("Oslo"));
var failing = new WeatherReport(new DownWeatherService());
Console.WriteLine(await failing.SummaryAsync("Oslo"));
public record Forecast(string City, double Temp, string Description);
public interface IWeatherService
{
Task<Forecast> GetForecastAsync(string city);
}
public class FakeWeatherService : IWeatherService
{
public Task<Forecast> GetForecastAsync(string city) =>
Task.FromResult(new Forecast(city, 22.0, "Sunny"));
}
public class DownWeatherService : IWeatherService
{
public Task<Forecast> GetForecastAsync(string city) =>
throw new HttpRequestException("503 Service Unavailable");
}
public class WeatherReport(IWeatherService weather)
{
public async Task<string> SummaryAsync(string city)
{
try
{
var f = await weather.GetForecastAsync(city);
return $"{f.City}: {f.Temp}°C, {f.Description}";
}
catch (HttpRequestException ex) { return $"{city}: unavailable ({ex.Message})"; }
}
}
$ dotnet run
Oslo: 22°C, Sunny
Oslo: unavailable (503 Service Unavailable)
WeatherReport has no idea which implementation it received. Swapping in a service that always fails lets you test the error path deterministically — something that is hard to arrange against a real API. In ASP.NET Core the same swap is one line of registration, builder.Services.AddScoped<IWeatherService, OpenWeatherService>(); see ASP.NET Core Basics.
Interface vs Abstract Class
| Question | Interface | Abstract class |
|---|---|---|
| Share implementation code? | Only default methods, no state | Yes, including fields |
| How many per class? | Any number | One |
| Relationship | ”can do” (IComparable, IDisposable) | “is a” (Dog is an Animal) |
| Constructors / protected members | Static constructors and protected members only (C# 8+); no instance constructors | Yes |
| Swap for a fake in tests | Natural fit | Possible, but awkward |
A useful default: start with an interface for anything that crosses a boundary (storage, network, clock), and reach for an abstract base class only when several implementations genuinely share code. The OOP lesson shows the abstract-class side.
Practice
- Add a shape. Add a
Square(double side)implementingIShapeto the first example and print its area next to the circle’s.
Solution
IShape[] shapes = { new Circle(5), new Square(3) };
foreach (var s in shapes)
Console.WriteLine($"{s.Name}: {s.Area():F2}");
public interface IShape { double Area(); string Name { get; } }
public class Circle(double r) : IShape
{
public double Area() => Math.PI * r * r;
public string Name => "Circle";
}
public class Square(double side) : IShape
{
public double Area() => side * side;
public string Name => "Square";
}
$ dotnet run
Circle: 78.54
Square: 9.00
- IComparable. Make
Version(int Major, int Minor)implementIComparable<Version>so thatList<Version>.Sort()orders2.1, 1.9, 2.0correctly.
Solution
var versions = new List<Version> { new(2, 1), new(1, 9), new(2, 0) };
versions.Sort();
Console.WriteLine(string.Join(" ", versions));
public record Version(int Major, int Minor) : IComparable<Version>
{
public int CompareTo(Version? other) =>
other is null ? 1 : (Major, Minor).CompareTo((other.Major, other.Minor));
public override string ToString() => $"{Major}.{Minor}";
}
$ dotnet run
1.9 2.0 2.1
Sort() with no arguments uses IComparable<T>; tuples already compare element by element.
- A fake clock. Define
IClock { DateTime Now { get; } }, aFixedClock, and aGreeterthat says “Good morning” before noon. Test it at 09:00 and 15:00.
Solution
foreach (var hour in new[] { 9, 15 })
Console.WriteLine(new Greeter(new FixedClock(new DateTime(2024, 1, 1, hour, 0, 0))).Greet());
public interface IClock { DateTime Now { get; } }
public class FixedClock(DateTime now) : IClock { public DateTime Now => now; }
public class Greeter(IClock clock)
{
public string Greet() => clock.Now.Hour < 12 ? "Good morning" : "Good afternoon";
}
$ dotnet run
Good morning
Good afternoon
Code that calls DateTime.Now directly cannot be tested at a chosen time; code that asks for an IClock can.