TL;DR
- Use interface contracts to decouple business logic from concrete implementations.
- Improves testability, maintainability, and promotes clean architecture.
INotificationService
allows injecting different strategies like Email or SMS.OrderService
depends only on the interface, not how notifications are sent.- Use Dependency Injection and Factory Pattern to switch implementations at runtime.
You’re building an e-commerce platform and need to send notifications, order confirmations, shipping updates, promotional emails. Right now you’re using an email service, but next month the marketing team wants SMS notifications too. Later, they might want push notifications or Slack alerts.
If you hard-code your notification logic to a specific email service, you’ll need to rewrite chunks of your application every time requirements change. But what if you could write your business logic once and swap out the notification method without touching existing code?
This is where interfaces shine. They create contracts that let you build flexible, testable systems where components can be swapped, extended, or mocked without breaking existing functionality.
What Is an Interface in C#?
An interface in C# is a contract that defines what methods, properties, and events a class must implement, but not how they should work. Think of it as a job description that lists required skills without dictating how those skills are used.
The power of interfaces lies in their ability to separate what something does from how it does it. Your code can depend on the “what” (the interface) while remaining completely ignorant of the “how” (the specific implementation).
public interface INotificationService
{
Task SendAsync(string recipient, string subject, string message);
Task<bool> IsValidRecipientAsync(string recipient);
NotificationStatus GetDeliveryStatus(string messageId);
}
This interface defines a contract for sending notifications. Any class that implements INotificationService
must provide these three methods, but each implementation can handle them completely differently.
Interface Implementation: Creating Flexible Services
Let’s see how multiple classes can implement the same interface to provide different notification strategies:
public class EmailNotificationService : INotificationService
{
private readonly SmtpClient _smtpClient;
private readonly ILogger<EmailNotificationService> _logger;
public EmailNotificationService(SmtpClient smtpClient, ILogger<EmailNotificationService> logger)
{
_smtpClient = smtpClient;
_logger = logger;
}
public async Task SendAsync(string recipient, string subject, string message)
{
try
{
var mailMessage = new MailMessage("noreply@shop.com", recipient, subject, message)
{
IsBodyHtml = true
};
await _smtpClient.SendMailAsync(mailMessage);
_logger.LogInformation("Email sent successfully to {Recipient}", recipient);
}
catch (SmtpException ex)
{
_logger.LogError(ex, "Failed to send email to {Recipient}", recipient);
throw;
}
}
public async Task<bool> IsValidRecipientAsync(string recipient)
{
// Email-specific validation
return !string.IsNullOrEmpty(recipient) &&
recipient.Contains("@") &&
await CheckEmailDeliverabilityAsync(recipient);
}
public NotificationStatus GetDeliveryStatus(string messageId)
{
// Check email delivery status via SMTP logs or third-party service
return NotificationStatus.Delivered;
}
private async Task<bool> CheckEmailDeliverabilityAsync(string email)
{
// Implementation would check MX records, blacklists, etc.
await Task.Delay(100); // Simulate async call
return true;
}
}
public class SmsNotificationService : INotificationService
{
private readonly HttpClient _httpClient;
private readonly SmsConfig _config;
private readonly ILogger<SmsNotificationService> _logger;
public SmsNotificationService(HttpClient httpClient, SmsConfig config, ILogger<SmsNotificationService> logger)
{
_httpClient = httpClient;
_config = config;
_logger = logger;
}
public async Task SendAsync(string recipient, string subject, string message)
{
var smsPayload = new
{
to = recipient,
message = $"{subject}: {message}",
apiKey = _config.ApiKey
};
var response = await _httpClient.PostAsJsonAsync(_config.ApiEndpoint, smsPayload);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("SMS sent successfully to {Recipient}", recipient);
}
else
{
_logger.LogError("Failed to send SMS to {Recipient}. Status: {StatusCode}",
recipient, response.StatusCode);
throw new NotificationException($"SMS delivery failed: {response.StatusCode}");
}
}
public async Task<bool> IsValidRecipientAsync(string recipient)
{
// Phone number validation
return !string.IsNullOrEmpty(recipient) &&
recipient.All(c => char.IsDigit(c) || c == '+' || c == '-') &&
recipient.Length >= 10;
}
public NotificationStatus GetDeliveryStatus(string messageId)
{
// Query SMS provider's delivery status API
return NotificationStatus.Pending;
}
}
Dependency Injection and Loose Coupling
Here’s where interfaces really prove their worth. Your business logic can depend on the interface, not specific implementations:
public class OrderService
{
private readonly INotificationService _notificationService;
private readonly IOrderRepository _orderRepository;
private readonly ILogger<OrderService> _logger;
// Constructor injection - we don't care which notification service we get
public OrderService(
INotificationService notificationService,
IOrderRepository orderRepository,
ILogger<OrderService> logger)
{
_notificationService = notificationService;
_orderRepository = orderRepository;
_logger = logger;
}
public async Task<OrderResult> ProcessOrderAsync(CreateOrderRequest request)
{
try
{
// Validate the order
var validationResult = ValidateOrder(request);
if (!validationResult.IsValid)
{
return OrderResult.Failed(validationResult.Errors);
}
// Create and save the order
var order = new Order
{
CustomerId = request.CustomerId,
Items = request.Items,
TotalAmount = request.Items.Sum(i => i.Price * i.Quantity),
Status = OrderStatus.Confirmed,
CreatedAt = DateTime.UtcNow
};
await _orderRepository.SaveAsync(order);
// Send confirmation - works with any notification service
await _notificationService.SendAsync(
request.CustomerEmail,
"Order Confirmation",
$"Your order #{order.Id} has been confirmed. Total: ${order.TotalAmount:F2}"
);
_logger.LogInformation("Order {OrderId} processed successfully for customer {CustomerId}",
order.Id, request.CustomerId);
return OrderResult.Success(order);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process order for customer {CustomerId}", request.CustomerId);
return OrderResult.Failed($"Order processing failed: {ex.Message}");
}
}
private ValidationResult ValidateOrder(CreateOrderRequest request)
{
var result = new ValidationResult();
if (request.Items == null || !request.Items.Any())
result.AddError("Order must contain at least one item");
if (string.IsNullOrEmpty(request.CustomerEmail))
result.AddError("Customer email is required");
return result;
}
}
To read more about coupling and cohesion in object-oriented programming, check out my Cohesion vs Coupling post.
Dependency Injection Configuration
In your startup configuration, you can easily switch between implementations:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
// Development: Use email notifications
if (Environment.IsDevelopment())
{
services.AddTransient<INotificationService, EmailNotificationService>();
}
// Production: Use SMS for critical notifications
else
{
services.AddTransient<INotificationService, SmsNotificationService>();
}
// Or use a factory pattern for multiple notification types
services.AddTransient<INotificationServiceFactory, NotificationServiceFactory>();
services.AddTransient<IOrderRepository, SqlOrderRepository>();
services.AddTransient<OrderService>();
}
}
Curious about how to configure dependency injection in ASP.NET Core? Check out my Dependency Injection in ASP.NET post.
Note: When runtime decisions determine the implementation (like user preference or feature toggles), constructor injection isn’t enough. That’s where the factory pattern shines.
Interface Contracts Visualization
Here’s how the interface contracts and implementations relate:
classDiagram class INotificationService { +SendAsync(recipient, subject, message) Task +IsValidRecipientAsync(recipient) Task<bool> +GetDeliveryStatus(messageId) NotificationStatus } class EmailNotificationService { +SendAsync(...) +IsValidRecipientAsync(...) +GetDeliveryStatus(...) } class SmsNotificationService { +SendAsync(...) +IsValidRecipientAsync(...) +GetDeliveryStatus(...) } class OrderService { -INotificationService _notificationService +ProcessOrderAsync(request) OrderResult } INotificationService <|.. EmailNotificationService INotificationService <|.. SmsNotificationService OrderService --> INotificationService : uses
Interface Contracts and Implementations
OrderService
depends on theINotificationService
interface, which is implemented by bothEmailNotificationService
andSmsNotificationService
for flexible notification handling.
Testing with Interfaces
Interfaces make unit testing straightforward by allowing you to create mock implementations:
[Test]
public async Task ProcessOrderAsync_ValidOrder_SendsNotification()
{
// Arrange
var mockNotificationService = new Mock<INotificationService>();
var mockOrderRepository = new Mock<IOrderRepository>();
var mockLogger = new Mock<ILogger<OrderService>>();
mockNotificationService
.Setup(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns(Task.CompletedTask);
mockOrderRepository
.Setup(x => x.SaveAsync(It.IsAny<Order>()))
.Returns(Task.CompletedTask);
var orderService = new OrderService(
mockNotificationService.Object,
mockOrderRepository.Object,
mockLogger.Object);
var request = new CreateOrderRequest
{
CustomerId = 123,
CustomerEmail = "customer@example.com",
Items = new List<OrderItem> { new OrderItem { Price = 10.00m, Quantity = 2 } }
};
// Act
var result = await orderService.ProcessOrderAsync(request);
// Assert
Assert.IsTrue(result.IsSuccess);
mockNotificationService.Verify(
x => x.SendAsync("customer@example.com", "Order Confirmation", It.IsAny<string>()),
Times.Once);
}
Interface Segregation: Keep It Focused
Don’t create monolithic interfaces that try to do everything. Instead, create focused interfaces that serve specific purposes:
// Bad - too many responsibilities
public interface IMegaService
{
Task SendEmailAsync(string to, string subject, string body);
Task SendSmsAsync(string phone, string message);
Task LogEventAsync(string eventName, object data);
Task SaveToFileAsync(string path, byte[] data);
Task<User> GetUserAsync(int id);
}
// Better - focused interfaces
public interface INotificationService
{
Task SendAsync(string recipient, string subject, string message);
}
public interface IEventLogger
{
Task LogEventAsync(string eventName, object data);
}
public interface IFileStorage
{
Task SaveAsync(string path, byte[] data);
Task<byte[]> ReadAsync(string path);
}
This approach makes your code more flexible, testable, and easier to understand. Classes can implement multiple focused interfaces rather than one bloated interface.
C# 8+ Default Interface Implementations
Modern C# allows you to provide default implementations in interfaces, which can be useful for adding new methods without breaking existing implementations:
public interface IAdvancedNotificationService : INotificationService
{
// New method with default implementation
Task<NotificationMetrics> GetMetricsAsync(DateTime startDate, DateTime endDate)
{
// Default implementation
return Task.FromResult(new NotificationMetrics
{
TotalSent = 0,
SuccessRate = 1.0,
Period = $"{startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}"
});
}
}
Use default implementations sparingly, they’re best for evolutionary changes to existing interfaces rather than core functionality.
Common Mistakes to Avoid
Over-abstraction: Not every class needs an interface. If you’re only ever going to have one implementation and no testing requirements, you might be over-engineering.
Deep interface hierarchies: Avoid creating complex inheritance chains with interfaces. Keep them flat and focused.
Leaky abstractions: Don’t let implementation details sneak into your interface. If your interface mentions “SQL” or “HTTP”, it’s probably too specific.
Interface explosion: Don’t create an interface for every single class. Use interfaces where they add real value, flexibility, testability, or decoupling.
Interfaces are fundamental tools for building maintainable, testable C# applications. They help you write code that’s loosely coupled, easily extensible, and simple to test. The key is using them thoughtfully, create contracts that make sense for your domain and provide real value to your architecture.
When you find yourself wanting to swap implementations, mock dependencies for testing, or build plugin-style architectures, interfaces are your best friend. They’re the foundation of modern dependency injection patterns and essential for creating professional-grade C# applications.
Interfaces are more than syntax, they’re contracts that let your code scale without fear.
Further Reading
- Prefer interfaces over abstract classes? Here’s why
- C# Default Interface Methods vs Abstract Methods
- C# Abstract Class vs Interface: 10 Real-World Interview Questions
- Object-Oriented Programming in C#
- SOLID Principles in C#
- DIP vs DI vs IoC: Understanding Key Software Design Concepts
Frequently Asked Questions
What is an interface in C#?
interface
keyword, as in public interface INotificationService
, and can contain method signatures, properties, events, and (since C# 8.0) default implementations. Interfaces enable multiple inheritance and decoupling in your code.When should I use an interface instead of an abstract class?
class MyClass : IInterface1, IInterface2
), or want to enable dependency injection and testing. Interfaces work best for defining capabilities that cross-cut class hierarchies, while abstract classes are better when you need shared implementation code or protected members.Can a class implement multiple interfaces in C#?
public class MyClass : IInterface1, IInterface2, IInterface3
. This allows a single class to fulfill multiple contracts simultaneously, enabling flexible and modular design. Unlike class inheritance, which is limited to a single base class, you can implement as many interfaces as needed.Can interfaces have default implementations in C#?
void Method() { /* implementation */ }
directly in the interface. This feature should be used sparingly and mainly for evolutionary changes to existing interfaces, not as a substitute for proper inheritance. Default implementations help add new methods to interfaces without breaking existing code.How do interfaces help with unit testing in C#?
Moq
or NSubstitute
. You can inject mock implementations of interfaces (e.g., Mock<INotificationService>
) to isolate the code under test and verify interactions without relying on real implementations. This enables testing components in isolation with predictable dependencies.What is interface segregation and why is it important?
INotificationService
, IEventLogger
, and IFileStorage
. This approach makes your code more maintainable, flexible, and allows classes to implement only what they need without unnecessary dependencies.How do interfaces support loose coupling in C#?
INotificationService
) rather than concrete implementations (EmailNotificationService
). This enables you to change implementations without modifying dependent code, as shown in the OrderService
example that works with any notification service. Loose coupling makes your code more maintainable, testable, and adaptable to changing requirements.How are interfaces used with dependency injection in C#?
INotificationService
) instead of concrete classes. The DI container then provides the appropriate implementation at runtime using configuration like services.AddTransient<INotificationService, EmailNotificationService>()
. This approach centralizes implementation selection, simplifies testing, and makes your code more modular.What are common mistakes to avoid when using interfaces in C#?
SQL
or HTTP
into interfaces), deep inheritance hierarchies, and interface explosion (too many tiny interfaces). Focus on creating interfaces that add real value by enabling multiple implementations, testing, or decoupling, and avoid creating an interface for every class.When should I not use an interface in C#?
abstract class
might be more appropriate if you need to share code rather than just define a contract.Related Posts
- Prefer Interfaces Over Abstract Classes in C#: Build Flexible, Testable, and Maintainable Code
- Polymorphism in C#: Write Extensible Code Without Fear
- C# Abstract Class vs Interface: 10 Real-World Questions You Should Ask
- Polymorphism in C#: How Template Method, Strategy, and Visitor Patterns Make Your Code Flexible
- When Inheritance Still Makes Sense in C#: Polymorphism Without Swapping