Introduction
Interfaces, abstract classes, and composition are all useful ways to structure C# applications, but they solve different problems. Choosing the wrong approach can produce unnecessary inheritance hierarchies, overly broad interfaces, or classes that are difficult to test.
The best choice depends on what the application needs to vary, what behavior is genuinely shared, and which dependencies should be replaceable.
1. Use interfaces to define capabilities
An interface describes a contract that a type agrees to implement. It is especially useful when multiple implementations should be interchangeable.
public interface INotificationSender
{
Task SendAsync(
string recipient,
string message,
CancellationToken cancellationToken);
}
Different implementations can satisfy the same contract:
public sealed class EmailNotificationSender
: INotificationSender
{
public Task SendAsync(
string recipient,
string message,
CancellationToken cancellationToken)
{
// Send an email through the configured provider.
return Task.CompletedTask;
}
}
public sealed class ConsoleNotificationSender
: INotificationSender
{
public Task SendAsync(
string recipient,
string message,
CancellationToken cancellationToken)
{
Console.WriteLine($"{recipient}: {message}");
return Task.CompletedTask;
}
}
These implementations are illustrative; a production email sender would need actual provider integration and error handling.
An application service can depend on the abstraction rather than a concrete implementation:
public sealed class NotificationService(
INotificationSender sender)
{
public Task NotifyAsync(
string recipient,
string message,
CancellationToken cancellationToken)
{
return sender.SendAsync(
recipient,
message,
cancellationToken);
}
}
This allows the application to select an implementation through dependency injection and makes the service easier to test.
2. Use abstract classes for shared implementation
An abstract class can provide shared state, common methods, and required operations for a related family of types.
public abstract class FileProcessor
{
public async Task ProcessAsync(
string path,
CancellationToken cancellationToken)
{
ValidatePath(path);
var content = await ReadAsync(
path,
cancellationToken);
await ProcessContentAsync(
content,
cancellationToken);
}
protected virtual void ValidatePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException(
"A path is required.",
nameof(path));
}
}
protected abstract Task<string> ReadAsync(
string path,
CancellationToken cancellationToken);
protected abstract Task ProcessContentAsync(
string content,
CancellationToken cancellationToken);
}
Derived classes can implement the file-reading and processing details while inheriting the shared workflow.
This is useful when types genuinely share a common lifecycle or implementation. However, inheritance creates a relationship between the base class and its subclasses, so changes to the base class can affect every derived type.
3. Use composition to combine behavior
Composition means a class delegates responsibilities to other objects rather than inheriting all of their behavior.
public interface IDiscountPolicy
{
decimal Apply(decimal subtotal);
}
public sealed class CheckoutService(
IDiscountPolicy discountPolicy)
{
public decimal CalculateTotal(decimal subtotal)
{
return discountPolicy.Apply(subtotal);
}
}
Different discount policies can be supplied without changing CheckoutService. This is particularly useful when behavior varies independently or when several classes need the same capability.
Composition also helps avoid deep inheritance trees where subclasses inherit behavior they do not need.
4. Avoid interfaces that add no meaningful flexibility
Not every class requires an interface. Creating an interface for every implementation can increase the number of files and abstractions without making the application easier to maintain.
An interface is valuable when it represents a meaningful boundary: a replaceable infrastructure dependency, a stable contract, or a capability implemented by multiple types.
If a class has one implementation and no meaningful substitution requirement, a direct dependency may be simpler. The decision can change as the application evolves.
5. Keep interfaces focused
An interface should describe a coherent responsibility. A large interface that combines unrelated operations forces implementations to support methods they may not need.
For example, a reporting service should not have to implement email sending simply because both capabilities were placed in a general-purpose interface.
Smaller contracts make dependencies easier to understand and test. They also help ensure that changes to one responsibility do not affect unrelated consumers.
6. Make the decision using practical questions
When choosing between these techniques, ask:
- Interface: Do I need a contract that multiple implementations can satisfy?
- Abstract class: Do related types share a genuine implementation or lifecycle?
- Composition: Can I build the behavior by combining smaller, independently replaceable collaborators?
- Concrete class: Would a simpler direct dependency be sufficient?
These options are not mutually exclusive. A class can implement an interface and compose several collaborators, while an abstract base class can implement a shared interface when that relationship is justified.
Conclusion
Interfaces define replaceable contracts, abstract classes share implementation among related types, and composition combines behavior without requiring inheritance. Favor the simplest design that expresses the real requirements, and introduce abstractions when they improve clarity, testing, or the ability to change the system safely.