SOLID Principles in the Real World: Beyond the Textbook Examples
There is a particular kind of code smell that only becomes visible when you need to change something. The feature request arrives, you open the class, and you realize that changing one behavior requires modifying three unrelated others, or that adding a new provider means forking a switch statement in six places, or that your test for the payment service is slower than your integration test suite because the class silently calls out to a logging service that writes to disk.
These are the failure modes that SOLID was designed to prevent — not as academic hygiene, but as practical pressure relief. Each principle addresses a specific failure mode in object-oriented design. Understanding the principle means understanding the failure mode it prevents, and knowing how to diagnose it from production symptoms rather than from a green-field thought experiment.
Concept
SOLID is a mnemonic: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. The principles were assembled by Robert Martin from a collection of earlier design observations. They are not axioms — they are heuristics with real trade-offs, and applying them without judgment produces over-engineered code that is harder to navigate than the violation it replaced.
The key is to apply them in response to observed pressure, not in anticipation of speculative pressure. A class that has one responsibility and you never need to split it has paid no cost from having one responsibility. A class that has three responsibilities and you change it six times a week is actively expensive.
Constraints
SOLID principles exist within constraints. Every abstraction layer added to satisfy a principle has a navigation cost: more files, more interfaces, more indirection. In a team of two working on a startup MVP, this cost is often larger than the benefit. In a team of twenty working on a financial platform with 400 services, the benefit is enormous. The principles scale with team size, domain complexity, and rate of change.
The other constraint is test coverage. SOLID principles are most valuable when you have tests that enforce the contracts they create. A Dependency Inversion refactoring that removes a concrete dependency is only meaningful if the test suite exercises the abstracted dependency. Without tests, the refactoring is aesthetic.
Trade-offs
Each principle introduces a trade-off between cohesion/indirection and flexibility/testability. The art is calibrating how much indirection your domain's rate of change justifies.
Code
Single Responsibility Principle (SRP): One Reason to Change
The textbook version says "a class should have only one reason to change." The operational version is: if two different stakeholders could request changes to the same class for independent reasons, the class has more than one responsibility.
// VIOLATION: UserService is responsible for user lifecycle AND email formatting AND audit logging
// Three stakeholders — auth team, marketing team, compliance team — can all force changes here
public class UserService_Violating
{
private readonly DbContext _db;
private readonly ISmtpClient _smtp;
private readonly ILogger _logger;
public async Task RegisterUserAsync(RegisterUserRequest request)
{
// User creation — auth team's concern
var user = new User { Email = request.Email, HashedPassword = Hash(request.Password) };
_db.Users.Add(user);
await _db.SaveChangesAsync();
// Email formatting and sending — marketing team's concern
var html = $"<h1>Welcome, {request.Email}!</h1><p>Click to confirm...</p>";
await _smtp.SendAsync(request.Email, "Welcome!", html);
// Audit logging — compliance team's concern
_logger.LogInformation("User {Email} registered at {Timestamp}", request.Email, DateTime.UtcNow);
}
}
// REFACTORED: three services, each owned by one stakeholder concern
public sealed class UserRegistrationService
{
private readonly IUserRepository _userRepository;
private readonly IWelcomeEmailSender _emailSender;
private readonly IUserAuditLogger _auditLogger;
public UserRegistrationService(
IUserRepository userRepository,
IWelcomeEmailSender emailSender,
IUserAuditLogger auditLogger)
{
_userRepository = userRepository;
_emailSender = emailSender;
_auditLogger = auditLogger;
}
public async Task RegisterAsync(RegisterUserCommand command, CancellationToken ct)
{
var user = User.Register(command.Email, command.PlainTextPassword);
await _userRepository.SaveAsync(user, ct);
await _emailSender.SendWelcomeAsync(user, ct);
await _auditLogger.LogRegistrationAsync(user, ct);
}
}
Open/Closed Principle (OCP): Open for Extension, Closed for Modification
OCP says that adding new behavior should not require modifying existing, tested code. The violation pattern is a switch or if-else chain that must be updated every time a new variant is added.
// VIOLATION: every new payment provider requires modifying this class
// Each modification is a regression risk for all existing providers
public class PaymentProcessor_Violating
{
public async Task<PaymentResult> ProcessAsync(PaymentMethod method, decimal amount)
{
return method.Type switch
{
"stripe" => await ProcessStripeAsync(method, amount),
"paypal" => await ProcessPaypalAsync(method, amount),
"klarna" => await ProcessKlarnaAsync(method, amount),
// New provider? Modify this file, re-test everything.
_ => throw new NotSupportedException($"Unsupported: {method.Type}")
};
}
}
// REFACTORED: new providers extend the system without touching existing code
public interface IPaymentProvider
{
string SupportedMethodType { get; }
Task<PaymentResult> ProcessAsync(PaymentMethod method, decimal amount, CancellationToken ct);
}
public sealed class StripePaymentProvider : IPaymentProvider
{
public string SupportedMethodType => "stripe";
public async Task<PaymentResult> ProcessAsync(
PaymentMethod method, decimal amount, CancellationToken ct)
{
// Stripe-specific implementation
var charge = await StripeClient.Charges.CreateAsync(new ChargeCreateOptions
{
Amount = (long)(amount * 100),
Currency = "usd",
Source = method.Token
});
return PaymentResult.FromStripe(charge);
}
}
// Dispatcher: closed to modification, open to new IPaymentProvider registrations
public sealed class PaymentDispatcher
{
private readonly IReadOnlyDictionary<string, IPaymentProvider> _providers;
public PaymentDispatcher(IEnumerable<IPaymentProvider> providers)
=> _providers = providers.ToDictionary(p => p.SupportedMethodType);
public async Task<PaymentResult> ProcessAsync(
PaymentMethod method, decimal amount, CancellationToken ct)
{
if (!_providers.TryGetValue(method.Type, out var provider))
throw new NotSupportedException($"No provider registered for '{method.Type}'.");
return await provider.ProcessAsync(method, amount, ct);
}
}
Liskov Substitution Principle (LSP): Subtypes Must Be Behaviorally Compatible
LSP is violated when a derived class changes the contract of the base class — often silently. The canonical C# trap is throwing NotImplementedException from a derived method, but the more subtle violation is strengthening a precondition or weakening a postcondition.
// VIOLATION: ReadOnlyOrderRepository extends OrderRepository but throws on writes.
// Code that depends on IOrderRepository will fail at runtime when it receives the read-only variant.
public class ReadOnlyOrderRepository_Violating : SqlOrderRepository
{
public override Task SaveAsync(Order order, CancellationToken ct)
=> throw new InvalidOperationException("This repository is read-only."); // LSP violation
}
// REFACTORED: separate the read and write contracts at the interface level
public interface IOrderReadRepository
{
Task<Order?> FindByIdAsync(OrderId id, CancellationToken ct);
Task<IReadOnlyList<Order>> GetByCustomerAsync(CustomerId customerId, CancellationToken ct);
}
public interface IOrderWriteRepository : IOrderReadRepository
{
Task SaveAsync(Order order, CancellationToken ct);
Task DeleteAsync(OrderId id, CancellationToken ct);
}
// The read-only implementation now correctly satisfies its declared contract
public sealed class ReadOnlyOrderRepository : IOrderReadRepository
{
private readonly OrderDbContext _db;
public ReadOnlyOrderRepository(OrderDbContext db) => _db = db;
public async Task<Order?> FindByIdAsync(OrderId id, CancellationToken ct)
=> await _db.Orders.AsNoTracking()
.SingleOrDefaultAsync(o => o.Id == id.Value, ct) is { } row
? OrderMapper.ToDomain(row)
: null;
public async Task<IReadOnlyList<Order>> GetByCustomerAsync(
CustomerId customerId, CancellationToken ct)
=> (await _db.Orders.AsNoTracking()
.Where(o => o.CustomerId == customerId.Value)
.ToListAsync(ct))
.Select(OrderMapper.ToDomain)
.ToList();
}
Interface Segregation Principle (ISP): Clients Shouldn't Depend on Methods They Don't Use
// VIOLATION: fat interface forces every implementor to provide all methods,
// even when only a subset is needed
public interface INotificationService_Fat
{
Task SendEmailAsync(string to, string subject, string body, CancellationToken ct);
Task SendSmsAsync(string phoneNumber, string message, CancellationToken ct);
Task SendPushNotificationAsync(string deviceToken, string title, string body, CancellationToken ct);
Task SendSlackMessageAsync(string channel, string message, CancellationToken ct);
}
// REFACTORED: segregated interfaces — each consumer depends only on what it uses
public interface IEmailSender
{
Task SendEmailAsync(string to, string subject, string body, CancellationToken ct);
}
public interface ISmsSender
{
Task SendSmsAsync(string phoneNumber, string message, CancellationToken ct);
}
public interface IPushNotifier
{
Task SendPushAsync(string deviceToken, string title, string body, CancellationToken ct);
}
// OrderShippedNotifier only needs email — not burdened by SMS or push contracts
public sealed class OrderShippedNotifier
{
private readonly IEmailSender _emailSender;
public OrderShippedNotifier(IEmailSender emailSender)
=> _emailSender = emailSender;
public Task NotifyAsync(Order order, CancellationToken ct)
=> _emailSender.SendEmailAsync(
to: order.CustomerEmail,
subject: $"Your order #{order.Id} has shipped!",
body: $"Expected delivery: {order.EstimatedDeliveryDate:d}",
ct: ct);
}
Dependency Inversion Principle (DIP): Depend on Abstractions, Not Concretions
// VIOLATION: high-level policy class directly instantiates low-level infrastructure
public class OrderFulfillmentService_Violating
{
private readonly SqlOrderRepository _repo; // concrete infrastructure type
private readonly SmtpEmailClient _emailClient; // concrete infrastructure type
public OrderFulfillmentService_Violating()
{
// Hard dependency — impossible to test without a real SQL server and SMTP server
_repo = new SqlOrderRepository(connectionString: "Server=prod-db;...");
_emailClient = new SmtpEmailClient(host: "smtp.company.com");
}
}
// REFACTORED: high-level policy depends on abstractions injected from outside
public sealed class OrderFulfillmentService
{
private readonly IOrderRepository _orderRepository; // abstraction
private readonly IFulfillmentEmailSender _emailSender; // abstraction
private readonly IFulfillmentEventPublisher _events; // abstraction
// All dependencies injected — substitutable in tests without production infrastructure
public OrderFulfillmentService(
IOrderRepository orderRepository,
IFulfillmentEmailSender emailSender,
IFulfillmentEventPublisher events)
{
_orderRepository = orderRepository;
_emailSender = emailSender;
_events = events;
}
public async Task FulfillAsync(OrderId orderId, CancellationToken ct)
{
var order = await _orderRepository.FindByIdAsync(orderId, ct)
?? throw new OrderNotFoundException(orderId);
order.MarkFulfilled(fulfilledAt: DateTimeOffset.UtcNow);
await _orderRepository.SaveAsync(order, ct);
await _emailSender.SendFulfillmentConfirmationAsync(order, ct);
await _events.PublishOrderFulfilledAsync(order, ct);
}
}