.NET & C# Development · Lesson 55 of 92
SOLID Principles in Real .NET Code (Not Just Slides)
The Context: OrderFlow API
All examples come from a simplified order management API with commands, payment processing, and notifications. No bank account shapes. No animal hierarchies.
S — Single Responsibility Principle
One reason to change. If your class changes for two different reasons, it has two responsibilities.
Before:
// Bad: handles business logic AND sends email AND logs
public class OrderService
{
public async Task PlaceOrderAsync(PlaceOrderCommand cmd)
{
// 1. Business logic
var order = new Order(cmd.CustomerId, cmd.Items);
await _repository.AddAsync(order);
// 2. Email — changes when template changes
await _emailClient.SendAsync(cmd.Email, "Order Confirmation", BuildEmailBody(order));
// 3. Logging — changes when log format changes
_logger.LogInformation("Order {Id} placed at {Time}", order.Id, DateTime.UtcNow);
}
}After — split into a thin handler that orchestrates, and separate services:
// Handler: only knows about the order creation sequence
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, OrderId>
{
private readonly IOrderRepository _orders;
private readonly IPublisher _publisher;
public PlaceOrderHandler(IOrderRepository orders, IPublisher publisher)
{
_orders = orders;
_publisher = publisher;
}
public async Task<OrderId> Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = new Order(cmd.CustomerId, cmd.Items);
await _orders.AddAsync(order, ct);
await _publisher.Publish(new OrderPlacedEvent(order.Id, cmd.Email), ct);
return order.Id;
}
}
// Email handler — only knows about sending confirmations, changes with email logic
public class SendOrderConfirmationHandler : INotificationHandler<OrderPlacedEvent>
{
public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
=> await _emailService.SendOrderConfirmationAsync(notification.Email, notification.OrderId, ct);
}Each class has exactly one axis of change.
O — Open/Closed Principle
Open for extension, closed for modification. Add new behavior without touching existing code.
Adding a new payment method should not require touching PaymentService.
Before:
// Bad: every new payment method requires modifying this switch
public class PaymentService
{
public Task<PaymentResult> ProcessAsync(PaymentRequest request)
{
return request.Method switch
{
"stripe" => ProcessStripeAsync(request),
"paypal" => ProcessPayPalAsync(request),
"crypto" => throw new NotImplementedException(), // always coming "soon"
_ => throw new InvalidOperationException("Unknown payment method.")
};
}
}After — introduce an abstraction and let DI wire in new providers:
// Abstraction
public interface IPaymentProvider
{
string Method { get; }
Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct);
}
// Existing providers — never modified when new ones are added
public class StripePaymentProvider : IPaymentProvider
{
public string Method => "stripe";
public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct) { ... }
}
public class PayPalPaymentProvider : IPaymentProvider
{
public string Method => "paypal";
public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct) { ... }
}
// New provider: zero changes to existing code
public class CryptoPaymentProvider : IPaymentProvider
{
public string Method => "crypto";
public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct) { ... }
}
// Dispatcher — also unchanged when providers are added
public class PaymentService
{
private readonly Dictionary<string, IPaymentProvider> _providers;
public PaymentService(IEnumerable<IPaymentProvider> providers)
=> _providers = providers.ToDictionary(p => p.Method);
public Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct)
{
if (!_providers.TryGetValue(request.Method, out var provider))
throw new InvalidOperationException($"No provider for method '{request.Method}'.");
return provider.ProcessAsync(request, ct);
}
}Register all providers once:
builder.Services.AddScoped<IPaymentProvider, StripePaymentProvider>();
builder.Services.AddScoped<IPaymentProvider, PayPalPaymentProvider>();
builder.Services.AddScoped<IPaymentProvider, CryptoPaymentProvider>();
builder.Services.AddScoped<PaymentService>();L — Liskov Substitution Principle
Subtypes must be substitutable for their base type without the caller caring which concrete type it has.
Violation — ReadOnlyOrderRepository throws on a write method from the interface:
// Bad: subtype breaks the contract
public class ReadOnlyOrderRepository : IOrderRepository
{
public Task<Order?> GetByIdAsync(int id) => _db.Orders.FindAsync(id).AsTask();
public Task AddAsync(Order order)
=> throw new NotSupportedException("This repository is read-only."); // LSP violation
}Fix — split the interface (see ISP below), so ReadOnlyOrderRepository never implements a method it can't support:
public interface IOrderReadRepository
{
Task<Order?> GetByIdAsync(int id, CancellationToken ct);
Task<IReadOnlyList<Order>> GetByCustomerAsync(int customerId, CancellationToken ct);
}
public interface IOrderWriteRepository
{
Task AddAsync(Order order, CancellationToken ct);
Task UpdateAsync(Order order, CancellationToken ct);
}
// Read-only implementation: only implements what it can support
public class ReadOnlyOrderRepository : IOrderReadRepository { ... }
// Full implementation: substitutable anywhere IOrderReadRepository is expected
public class OrderRepository : IOrderReadRepository, IOrderWriteRepository { ... }I — Interface Segregation Principle
Clients should not depend on methods they don't use. Fat interfaces force classes to implement no-ops or throw.
Before:
// Bad: everything gets all these methods even if it needs one
public interface INotificationService
{
Task SendEmailAsync(string to, string subject, string body);
Task SendSmsAsync(string phone, string message);
Task SendPushAsync(string deviceToken, string message);
Task SendSlackAsync(string channel, string message);
}
// SMS service forced to implement irrelevant methods
public class SmsNotificationService : INotificationService
{
public Task SendSmsAsync(string phone, string message) { ... }
public Task SendEmailAsync(...) => throw new NotImplementedException();
public Task SendPushAsync(...) => throw new NotImplementedException();
public Task SendSlackAsync(...) => throw new NotImplementedException();
}After:
public interface IEmailSender { Task SendEmailAsync(string to, string subject, string body, CancellationToken ct); }
public interface ISmsSender { Task SendSmsAsync(string phone, string message, CancellationToken ct); }
public interface IPushSender { Task SendPushAsync(string deviceToken, string message, CancellationToken ct); }
public interface ISlackSender { Task SendSlackAsync(string channel, string message, CancellationToken ct); }
// Each class implements only what it does
public class SmsNotificationService : ISmsSender { ... }
public class EmailNotificationService : IEmailSender { ... }
// Composed service if you need multi-channel
public class OrderNotifier
{
private readonly IEmailSender _email;
private readonly ISmsSender _sms;
public OrderNotifier(IEmailSender email, ISmsSender sms)
{
_email = email;
_sms = sms;
}
}D — Dependency Inversion Principle
Depend on abstractions, not concretions. High-level modules (business logic) should not depend on low-level modules (EF Core, SMTP clients). Both depend on interfaces.
ASP.NET Core's DI container is exactly this principle at the infrastructure level.
Before:
// Bad: OrderService creates its own dependencies — can't be unit tested, can't swap implementations
public class OrderService
{
private readonly AppDbContext _db = new AppDbContext(new DbContextOptions<AppDbContext>());
private readonly SmtpEmailClient _email = new SmtpEmailClient("smtp.company.com", 587);
}After:
// Good: depends on interfaces, injected by the container
public class OrderService
{
private readonly IOrderRepository _orders;
private readonly IEmailSender _email;
public OrderService(IOrderRepository orders, IEmailSender email)
{
_orders = orders;
_email = email;
}
}
// Registrations in Program.cs — the composition root
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
// In tests — swap for fakes without changing OrderService at all
services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
services.AddScoped<IEmailSender, FakeEmailSender>();The key insight: OrderService has no using directive for Entity Framework or any SMTP library. It only knows about interfaces defined in the domain layer.
When NOT to Follow SOLID
SOLID is a tool, not a religion. Over-engineering is real:
- Single endpoint microservice: SRP through 5 layers of abstraction for
GET /healthis absurd - Throwaway scripts and migration utilities: interfaces and DI add zero value in a 50-line script
- CRUD endpoints with no business logic: a repository that just proxies EF Core is often fine to use directly
- Small teams, early-stage products: premature abstraction makes code harder to read and change, not easier
The signal to apply SOLID: you've already felt the pain of not having it — tests are hard to write, adding a new case requires touching five files, or bugs keep appearing in unrelated places when you change something.
Key Takeaways
- SRP: each class changes for one reason — split by axis of change, not by type of code
- OCP: use a strategy/provider pattern behind an interface; registration in the DI container is the extension point
- LSP: if a subtype can't honour the interface contract, split the interface
- ISP: small, focused interfaces beat one large fat interface — compose them where needed
- DIP: depend on abstractions; the DI container is your composition root — production and test code only swap registrations