SOLID Principles in C# — Real Project Example, Why Each One Exists, and When NOT to Apply Them
A practical SOLID guide built around a real order-processing service. We start with code that violates every principle, then refactor one principle at a time — SRP, OCP, LSP, ISP, DIP — showing exactly what problem each one solves, the honest advantages and disadvantages, when to break the rule, and a code-review checklist you can paste into your PR template.
- Author
- Randhir Jassal
- Published
- Reading time
- 26 min read
SOLID Principles in C# — Real Project Example, Why Each One Exists, and When NOT to Apply Them
Most SOLID articles teach the principles in isolation: a
Shapeclass, aRectanglethat violates LSP, aLoggerthat violates SRP. You finish the article having memorised five acronyms but with no idea what to do on Monday morning when your real order-processing service has 11 reasons to change and aswitchstatement on payment type.This guide is different. We start with a real, ugly order-processing service that violates all five principles. Then we refactor it, one principle at a time, watching the design get cleaner. Along the way: what problem each principle actually solves, the honest trade-offs (over-engineering is a real cost), and the rare cases where breaking the rule is the right call.
TL;DR
- S — Single Responsibility: one class, one reason to change. Solves "every bug fix touches 6 unrelated tests."
- O — Open / Closed: open for extension, closed for modification. Solves "every new payment provider means editing the same file."
- L — Liskov Substitution: subtypes must honour the contract of their base. Solves "the
Squarethat derives fromRectangleand breaks every caller." - I — Interface Segregation: small, focused interfaces. Solves "every implementation has 7
throw new NotImplementedException()s." - D — Dependency Inversion: depend on abstractions, not concretes. Solves "I can''t unit-test this without a database."
SOLID is not a religion. It is a set of defaults that make code easier to change in the medium term. Apply them when complexity warrants it; ignore them in scripts, prototypes, and one-off code.
1. The starting point — a real order service that breaks every principle
We''ll work from a single example throughout the article: an e-commerce checkout service. Here is the original "it works, ship it" version — the kind of code that gets written under deadline pressure and then becomes a nightmare 6 months later.
public class OrderService
{
public void PlaceOrder(Order order)
{
// 1. Validate
if (order.Items.Count == 0)
throw new InvalidOperationException("Empty order");
if (order.Total <= 0)
throw new InvalidOperationException("Invalid total");
// 2. Apply discount based on customer type
if (order.Customer.Type == "Gold") order.Total *= 0.85m;
else if (order.Customer.Type == "Silver") order.Total *= 0.92m;
else if (order.Customer.Type == "New") order.Total *= 0.95m;
// 3. Charge payment
if (order.PaymentType == "Card")
{
var stripe = new StripeClient("sk_live_...");
var charge = stripe.Charge(order.Total, order.CardToken);
if (!charge.Success) throw new Exception("Card declined");
}
else if (order.PaymentType == "UPI")
{
var razorpay = new RazorpayClient("rzp_live_...");
var charge = razorpay.ChargeUpi(order.Total, order.UpiId);
if (!charge.Success) throw new Exception("UPI failed");
}
else if (order.PaymentType == "COD")
{
// no charge yet
}
// 4. Save to database
using var conn = new SqlConnection("Server=...;Database=Orders;");
conn.Open();
var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO Orders (Id, CustomerId, Total, Status) VALUES (@i, @c, @t, ''Paid'')";
cmd.Parameters.AddWithValue("@i", order.Id);
cmd.Parameters.AddWithValue("@c", order.Customer.Id);
cmd.Parameters.AddWithValue("@t", order.Total);
cmd.ExecuteNonQuery();
// 5. Send confirmation email
var smtp = new SmtpClient("smtp.example.com", 587)
{
Credentials = new NetworkCredential("noreply@example.com", "..."),
};
smtp.Send("noreply@example.com", order.Customer.Email,
"Your order", $"Total: {order.Total:C}");
// 6. Log
File.AppendAllText("orders.log",
$"{DateTime.UtcNow}\t{order.Id}\t{order.Total}\n");
}
}
This class does six things. It validates, prices, charges, persists, notifies, logs. It hard-codes three external services. It can''t be tested without a real Stripe key, a real database, and an SMTP server. Every new payment method, every discount rule change, every email template tweak — they all touch the same method.
Let''s fix it, principle by principle.
2. S — Single Responsibility Principle
"A class should have one, and only one, reason to change." — Robert C. Martin
2.1 What problem it solves
When one class has multiple reasons to change, every change risks breaking the others. The marketing team changes the email copy → you accidentally break the payment flow. The DBA renames a column → the discount calculator stops working. Tests for the order flow break when you fix an email typo.
The single responsibility isn''t "one thing the class does" — it''s "one stakeholder who can request a change." Payment processing changes because the finance team adds a provider. Email templates change because marketing wants a different layout. Database schema changes because the DBA. These are three different stakeholders → three different classes.
2.2 Refactor — extract by stakeholder
public class OrderService
{
private readonly OrderValidator _validator;
private readonly DiscountCalculator _discountCalculator;
private readonly PaymentProcessor _paymentProcessor;
private readonly OrderRepository _repository;
private readonly OrderNotifier _notifier;
private readonly OrderAuditLogger _logger;
public OrderService(
OrderValidator validator,
DiscountCalculator discountCalculator,
PaymentProcessor paymentProcessor,
OrderRepository repository,
OrderNotifier notifier,
OrderAuditLogger logger)
{
_validator = validator;
_discountCalculator = discountCalculator;
_paymentProcessor = paymentProcessor;
_repository = repository;
_notifier = notifier;
_logger = logger;
}
public async Task PlaceOrderAsync(Order order)
{
_validator.Validate(order);
order.Total = _discountCalculator.ApplyDiscount(order);
await _paymentProcessor.ChargeAsync(order);
await _repository.SaveAsync(order);
await _notifier.SendConfirmationAsync(order);
await _logger.LogAsync(order);
}
}
Now OrderService does one thing: orchestrate the order workflow. Each collaborator has its own reason to change.
public class OrderValidator
{
public void Validate(Order order)
{
if (order.Items.Count == 0)
throw new ValidationException("Empty order");
if (order.Total <= 0)
throw new ValidationException("Invalid total");
}
}
public class OrderAuditLogger
{
private readonly ILogger<OrderAuditLogger> _log;
public OrderAuditLogger(ILogger<OrderAuditLogger> log) => _log = log;
public Task LogAsync(Order order)
{
_log.LogInformation("Order {OrderId} placed for {Total:C}", order.Id, order.Total);
return Task.CompletedTask;
}
}
2.3 Advantages
- Tests are tiny.
OrderValidatorcan be tested with zero infrastructure. - Parallel work. Two developers can change
OrderNotifierandDiscountCalculatorwithout merge conflicts. - Bugs stay local. A typo in the email template can''t break the payment flow.
2.4 Disadvantages (the honest part)
- More files. A simple workflow that was 1 class is now 7.
- Constructor explosion.
OrderServicenow needs 6 dependencies. - Indirection. A new developer must jump through 5 files to read the full flow.
When to apply: any time a class has > 2 distinct stakeholders or has grown past ~150 lines.
When NOT to apply: scripts, one-off migrations, simple CRUD where the class is genuinely a data shape.
3. O — Open / Closed Principle
"Software entities should be open for extension, but closed for modification." — Bertrand Meyer
3.1 What problem it solves
Look at the original if (PaymentType == "Card") … else if (PaymentType == "UPI") …. Every new payment provider means:
- Edit
OrderService(or whoever owns the switch). - Re-test everything in the file.
- Risk merging conflicts with other people editing the same switch.
OCP says: when you anticipate variation along an axis (payment providers, discount rules, export formats), build the system so that adding a new variant doesn''t require editing existing code — only adding new code.
3.2 Refactor — strategy pattern
public interface IPaymentMethod
{
string Code { get; }
Task ChargeAsync(Order order);
}
public class CardPaymentMethod : IPaymentMethod
{
public string Code => "Card";
private readonly IStripeClient _stripe;
public CardPaymentMethod(IStripeClient stripe) => _stripe = stripe;
public async Task ChargeAsync(Order order)
{
var charge = await _stripe.ChargeAsync(order.Total, order.CardToken);
if (!charge.Success) throw new PaymentDeclinedException(charge.Message);
}
}
public class UpiPaymentMethod : IPaymentMethod
{
public string Code => "UPI";
private readonly IRazorpayClient _razorpay;
public UpiPaymentMethod(IRazorpayClient razorpay) => _razorpay = razorpay;
public async Task ChargeAsync(Order order)
{
var charge = await _razorpay.ChargeUpiAsync(order.Total, order.UpiId);
if (!charge.Success) throw new PaymentDeclinedException(charge.Message);
}
}
public class CodPaymentMethod : IPaymentMethod
{
public string Code => "COD";
public Task ChargeAsync(Order order) => Task.CompletedTask; // charge happens on delivery
}
public class PaymentProcessor
{
private readonly IReadOnlyDictionary<string, IPaymentMethod> _methods;
public PaymentProcessor(IEnumerable<IPaymentMethod> methods) =>
_methods = methods.ToDictionary(m => m.Code, StringComparer.OrdinalIgnoreCase);
public Task ChargeAsync(Order order)
{
if (!_methods.TryGetValue(order.PaymentType, out var method))
throw new NotSupportedException($"Payment type ''{order.PaymentType}'' is not supported");
return method.ChargeAsync(order);
}
}
Wiring in DI:
services.AddScoped<IPaymentMethod, CardPaymentMethod>();
services.AddScoped<IPaymentMethod, UpiPaymentMethod>();
services.AddScoped<IPaymentMethod, CodPaymentMethod>();
services.AddScoped<PaymentProcessor>();
Now adding "Apple Pay" is:
- Create
ApplePayPaymentMethod : IPaymentMethod. - Register it in DI.
- Ship.
Zero changes to PaymentProcessor. Zero changes to existing payment methods. That''s OCP.
3.3 Same shape — discount strategies
public interface IDiscountRule
{
bool Applies(Order order);
decimal Apply(decimal total);
}
public class GoldCustomerDiscount : IDiscountRule
{
public bool Applies(Order o) => o.Customer.Type == "Gold";
public decimal Apply(decimal total) => total * 0.85m;
}
public class FirstOrderDiscount : IDiscountRule
{
private readonly IOrderHistory _history;
public FirstOrderDiscount(IOrderHistory history) => _history = history;
public bool Applies(Order o) => _history.CountFor(o.Customer.Id) == 0;
public decimal Apply(decimal total) => total * 0.90m;
}
public class DiscountCalculator
{
private readonly IEnumerable<IDiscountRule> _rules;
public DiscountCalculator(IEnumerable<IDiscountRule> rules) => _rules = rules;
public decimal ApplyDiscount(Order order)
{
var total = order.Total;
foreach (var rule in _rules.Where(r => r.Applies(order)))
total = rule.Apply(total);
return total;
}
}
A new "Black Friday 10% off" rule is one new class. The calculator never changes.
3.4 Advantages
- Safe extension. Adding a feature can''t break existing ones — you''re only adding code, not editing it.
- Plug-in style. Third parties (or different teams) can ship new strategies without touching shared code.
3.5 Disadvantages
- Premature abstraction is expensive. Designing for OCP before you have two real variants creates "future-proof" interfaces that never get a second implementation.
- More indirection. Reading the discount flow now means jumping through every
IDiscountRuleimplementation.
When to apply: when you already have two concrete variants of the same operation. The third one is the moment OCP pays off.
When NOT to apply: "we might add another payment provider one day" is not enough. Wait until you actually have two.
4. L — Liskov Substitution Principle
"Subtypes must be substitutable for their base types without altering the correctness of the program." — Barbara Liskov
4.1 What problem it solves
If a function takes Bird and you pass it a Penguin (a Bird that can''t fly), it should still work. If it doesn''t — if Penguin.Fly() throws — you''ve broken the contract that every caller of Bird is depending on.
LSP is the principle most often violated while looking like good OO. The classic offender: Square : Rectangle because "every square is a rectangle, right?"
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area() => Width * Height;
}
public class Square : Rectangle
{
public override int Width { set { base.Width = value; base.Height = value; } }
public override int Height { set { base.Width = value; base.Height = value; } }
}
void DoubleArea(Rectangle r)
{
r.Width = 4;
r.Height = 5;
Console.WriteLine(r.Area()); // Caller expects 20. With Square: gets 25.
}
The Square is-a Rectangle taxonomically, but it is not substitutable as a Rectangle in code. LSP says: if subclassing breaks the contract, your inheritance is wrong.
4.2 LSP in our order example
In the original code (and even in our refactored version), there''s a subtle LSP risk:
public class CodPaymentMethod : IPaymentMethod
{
public string Code => "COD";
public Task ChargeAsync(Order order) => Task.CompletedTask; // does nothing!
}
CodPaymentMethod.ChargeAsync doesn''t actually charge anything. If a caller wrote:
await _paymentProcessor.ChargeAsync(order);
order.PaidAt = DateTime.UtcNow; // assume payment succeeded
…they''d be wrong for COD orders. The interface contract says "charge the order"; COD silently doesn''t. That''s an LSP violation.
4.3 The fix — make the contract honest
Option A: rename the contract to match the reality.
public interface IPaymentMethod
{
string Code { get; }
Task<PaymentResult> ProcessAsync(Order order);
}
public record PaymentResult(PaymentStatus Status, string? TransactionId);
public enum PaymentStatus { Charged, DeferredToDelivery, Declined }
public class CodPaymentMethod : IPaymentMethod
{
public string Code => "COD";
public Task<PaymentResult> ProcessAsync(Order order)
=> Task.FromResult(new PaymentResult(PaymentStatus.DeferredToDelivery, null));
}
Now every caller has to deal with the DeferredToDelivery case explicitly. The contract is honest; there is no surprise.
Option B: split the interface so COD doesn''t pretend to be a payment.
public interface IImmediatePaymentMethod : IPaymentMethod { }
public interface IDeferredPaymentMethod : IPaymentMethod { }
Either way: make the type system tell the truth. That''s LSP.
4.4 Advantages
- Polymorphism is safe. You can write
foreach (var m in methods) m.Process(order)and trust the behaviour. - Refactoring is safer. Replacing one implementation with another can''t introduce silent semantic changes.
4.5 Disadvantages
- It limits inheritance. Often the right design ends up being composition or interface segregation instead of clean class hierarchies.
- It demands precise contract documentation. The interface alone isn''t enough — you need to know what "Charge" means.
When to apply: any time you''re tempted to inherit + override behaviour. Ask: "does the subtype honour every promise the base made?"
When NOT to apply: rarely an over-application problem; LSP is one principle that''s almost always worth respecting.
5. I — Interface Segregation Principle
"Clients should not be forced to depend on methods they do not use." — Robert C. Martin
5.1 What problem it solves
Fat interfaces force every implementer to handle every method, even ones that don''t apply. A IPrinter interface with Print, Scan, Fax, Email means a print-only device has to throw new NotSupportedException from three methods. Worse: callers that only need Print now drag in a dependency that has every other method as a public surface.
5.2 LSP and ISP in our order example
Suppose we initially designed IPaymentMethod like this:
public interface IPaymentMethod
{
string Code { get; }
Task<PaymentResult> ProcessAsync(Order order);
Task RefundAsync(string transactionId, decimal amount);
Task<RecurringSchedule> SetupRecurringAsync(Order order);
Task<DisputeInfo> GetDisputeAsync(string transactionId);
}
Now CodPaymentMethod has to implement RefundAsync (you can''t refund cash you never collected) and SetupRecurringAsync (COD isn''t recurring) and GetDisputeAsync (no dispute system for cash).
It ends up like:
public class CodPaymentMethod : IPaymentMethod
{
public Task<PaymentResult> ProcessAsync(Order order) { /* real */ }
public Task RefundAsync(string txId, decimal amount)
=> throw new NotSupportedException("COD cannot be refunded electronically");
public Task<RecurringSchedule> SetupRecurringAsync(Order order)
=> throw new NotSupportedException();
public Task<DisputeInfo> GetDisputeAsync(string txId)
=> throw new NotSupportedException();
}
Every caller now has to wonder which methods will throw. The interface is lying.
5.3 Refactor — split into capabilities
public interface IPaymentMethod
{
string Code { get; }
Task<PaymentResult> ProcessAsync(Order order);
}
public interface IRefundable
{
Task RefundAsync(string transactionId, decimal amount);
}
public interface IRecurringCapable
{
Task<RecurringSchedule> SetupRecurringAsync(Order order);
}
public interface IDisputable
{
Task<DisputeInfo> GetDisputeAsync(string transactionId);
}
Now implementations declare only what they support:
public class CardPaymentMethod : IPaymentMethod, IRefundable, IRecurringCapable, IDisputable
{
// all four implemented for real
}
public class CodPaymentMethod : IPaymentMethod
{
// only the one capability — clean, honest
}
Callers ask for the capability they need:
public class RefundService
{
public async Task RefundAsync(Order order)
{
if (order.PaymentMethod is not IRefundable refundable)
throw new InvalidOperationException("This payment method cannot be refunded");
await refundable.RefundAsync(order.TransactionId!, order.Total);
}
}
5.4 Advantages
- Implementations are simpler — only the methods that matter.
- Type system encodes capabilities —
if (x is IRefundable)is precise;NotSupportedExceptionis not. - Less coupling — code that only refunds doesn''t need to know about recurring billing.
5.5 Disadvantages
- More interfaces. Five tiny interfaces instead of one big one.
- Capability checks at call sites —
if (x is IRefundable)pattern repeats.
When to apply: when even one implementer is forced to throw NotSupportedException from a method, you have an ISP violation. Split it.
When NOT to apply: don''t split a 4-method interface into 4 single-method interfaces just on principle. Split when there''s actual divergence in who implements what.
6. D — Dependency Inversion Principle
"High-level modules should not depend on low-level modules. Both should depend on abstractions." — Robert C. Martin
6.1 What problem it solves
In the original code:
var stripe = new StripeClient("sk_live_...");
using var conn = new SqlConnection("Server=...;Database=Orders;");
var smtp = new SmtpClient("smtp.example.com", 587);
File.AppendAllText("orders.log", ...);
OrderService directly instantiates Stripe, SQL Server, SMTP, the filesystem. The high-level "place an order" logic is welded to four low-level infrastructure choices.
Consequences:
- You can''t unit-test. Every test requires a real database, real SMTP server, real Stripe key.
- You can''t swap implementations. Move from Stripe to Razorpay? Rewrite
OrderService. Move from SQL Server to Postgres? RewriteOrderService. - The high-level logic is contaminated with low-level concerns (connection strings, SMTP credentials).
DIP says: invert the arrows. The high-level module defines an abstraction of what it needs; low-level modules implement that abstraction. The high-level module doesn''t even know which implementation it''s using.
6.2 Refactor — depend on abstractions
public interface IOrderRepository
{
Task SaveAsync(Order order);
}
public interface INotificationSender
{
Task SendConfirmationAsync(Order order);
}
public interface IStripeClient
{
Task<ChargeResult> ChargeAsync(decimal amount, string token);
}
Implementations live in the infrastructure project:
public class SqlOrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public SqlOrderRepository(AppDbContext db) => _db = db;
public async Task SaveAsync(Order order)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync();
}
}
public class EmailNotificationSender : INotificationSender
{
private readonly ISmtpClient _smtp;
public EmailNotificationSender(ISmtpClient smtp) => _smtp = smtp;
public async Task SendConfirmationAsync(Order order)
{
await _smtp.SendAsync(order.Customer.Email, "Your order", $"Total: {order.Total:C}");
}
}
And OrderService doesn''t know any of this. It just sees abstractions:
public class OrderService
{
private readonly IOrderRepository _repo;
private readonly INotificationSender _notifier;
public OrderService(IOrderRepository repo, INotificationSender notifier)
{
_repo = repo;
_notifier = notifier;
}
// ...
}
DI container wires it all together in one place:
services.AddDbContext<AppDbContext>(o => o.UseSqlServer(conn));
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<INotificationSender, EmailNotificationSender>();
services.AddScoped<IStripeClient, StripeClient>();
services.AddScoped<OrderService>();
6.3 Now you can test
[Fact]
public async Task PlaceOrder_SavesAndNotifies()
{
var repo = new InMemoryOrderRepository();
var notifier = new FakeNotificationSender();
var payment = new FakePaymentProcessor();
var service = new OrderService(repo, notifier, payment, ...);
await service.PlaceOrderAsync(new Order { /* ... */ });
Assert.Single(repo.Saved);
Assert.Single(notifier.Sent);
}
Zero infrastructure, runs in 5 ms. That''s only possible because of DIP.
6.4 Advantages
- Testability. This is the headline benefit. Fakes / mocks substitute for real systems.
- Swap implementations. SQL → Postgres → Mongo without touching
OrderService. - Composition root. All wiring lives in one place (
Program.cs), so the cost of changing it is bounded.
6.5 Disadvantages
- Interface proliferation. Every infrastructure concern gets an interface, even one-implementer ones.
- Indirection cost. Reading a flow means jumping from interface to implementation.
- DI container learning curve. New developers must learn the wiring conventions.
When to apply: any class that talks to a database, an external service, the filesystem, the clock, or random — depend on an abstraction.
When NOT to apply: pure value objects, simple utilities (Math.Max-style), or classes that genuinely have only one possible implementation forever (e.g., JsonSerializer).
7. The fully-refactored order service — all five principles applied
public class OrderService
{
private readonly IOrderValidator _validator;
private readonly IDiscountCalculator _discountCalculator;
private readonly PaymentProcessor _paymentProcessor;
private readonly IOrderRepository _repository;
private readonly INotificationSender _notifier;
private readonly ILogger<OrderService> _log;
public OrderService(
IOrderValidator validator,
IDiscountCalculator discountCalculator,
PaymentProcessor paymentProcessor,
IOrderRepository repository,
INotificationSender notifier,
ILogger<OrderService> log)
{
_validator = validator;
_discountCalculator = discountCalculator;
_paymentProcessor = paymentProcessor;
_repository = repository;
_notifier = notifier;
_log = log;
}
public async Task PlaceOrderAsync(Order order)
{
_validator.Validate(order);
order.Total = _discountCalculator.ApplyDiscount(order);
var payment = await _paymentProcessor.ProcessAsync(order);
order.PaymentStatus = payment.Status;
order.TransactionId = payment.TransactionId;
await _repository.SaveAsync(order);
await _notifier.SendConfirmationAsync(order);
_log.LogInformation(
"Order {OrderId} placed for {Total:C} ({Status})",
order.Id, order.Total, payment.Status);
}
}
What changed:
- SRP — orchestration only. Six collaborators, each with one stakeholder.
- OCP — add a payment method or discount rule without editing this class.
- LSP —
PaymentResultmakes deferred-vs-immediate payment explicit. - ISP —
IRefundable,IRecurringCapableseparate fromIPaymentMethod. - DIP — every collaborator is an interface. Testable in isolation.
Compare to the original 60-line method that talked to Stripe, SQL, SMTP, and the filesystem directly. Same behaviour. Wildly different maintainability.
8. SOLID + common patterns — where each principle naturally appears
| Pattern | Principle it embodies |
|---|---|
Strategy (IPaymentMethod) | OCP |
Specification (IDiscountRule) | OCP, SRP |
Repository (IOrderRepository) | DIP, SRP |
Adapter (wrap Stripe SDK in IStripeClient) | DIP, ISP |
| Decorator (caching repository wraps real repository) | OCP, SRP |
| Chain of Responsibility (validation pipeline) | OCP, SRP |
| Visitor (over a closed type hierarchy) | OCP |
| Pipeline / Middleware (ASP.NET Core) | OCP, SRP |
You usually arrive at these patterns by trying to honour SOLID, not the other way round. Don''t memorise patterns first; let the principles guide you to them.
9. Use cases — concrete decisions, not theory
9.1 "Should I split this class?"
Ask: does this class change for more than one reason? If a database schema change and a UI label change and a third-party API change all touch the same file, split it.
9.2 "Should I introduce an interface?"
Ask: will I ever swap this for a different implementation (production / test / staging / future provider)? If yes, introduce the interface. If no (e.g., it''s Math.Sqrt), don''t.
9.3 "Should I add this method to the existing interface?"
Ask: would every current implementer have a non-trivial implementation for it? If even one would throw new NotSupportedException, you have ISP pressure — create a new capability interface instead.
9.4 "Should I inherit or compose?"
Default to compose. Only inherit when the subtype is a true, contract-honouring specialisation. If you''re tempted to override behaviour that callers don''t expect to vary, that''s an LSP smell — use composition.
9.5 "Should I use ''new SomeService()'' inside this class?"
Almost never. If SomeService is anything other than a value object (record, DTO), inject it. The only exception is creating instances inside a factory whose explicit job is creation.
9.6 "I have one implementation. Do I need an interface?"
For pure logic / value objects: no. For anything that touches infrastructure (DB, HTTP, clock, IO): yes — because tests need a fake, even if production never has a second implementation.
10. The honest disadvantages — when SOLID is a tax, not a gift
-
Over-engineering small problems. A 30-line script that reads a CSV and writes a row to a database does not need five interfaces, a DI container, and a strategy pattern. SOLID is for code that lives.
-
Cognitive load. A new developer reading the refactored order service must hold 6 collaborators in mind to understand one workflow. The original was 60 ugly lines, but it was 60 lines of one file.
-
Indirection makes "go to definition" lie. Click on
_repository.SaveAsync(...)→ land on an interface → now hunt the implementation across 4 candidates. IDEs help, but the cost is real. -
Premature abstraction is worse than no abstraction. Designing for variation that never arrives gives you the cost of indirection with none of the benefits.
-
DI containers are a learning curve. "Why is this nullable?" "Where is this registered?" "Why does my scoped service throw at startup?" — these are all DI container questions a developer has to learn.
These are real costs. The right question isn''t "should I apply SOLID?" — it''s "is this code going to be edited by multiple people over years, or is it a one-shot?"
11. When to break each rule (it''s not heresy)
- Break SRP in early prototypes. One ugly class that gets the demo working is better than five clean ones that don''t.
- Break OCP when you know you''ll only ever have one variant. A
Loggerthat writes to stdout doesn''t need a strategy interface. - Break LSP … no, don''t. This one is a near-universal rule.
- Break ISP for very stable, very wide interfaces (
IDbConnection). The cost of breaking upIDbConnectionwould outweigh the benefit. - Break DIP for pure value types, sealed framework types, or one-off scripts.
The principle of least surprise: write idiomatic .NET, follow SOLID by default, but when breaking the rule makes the code clearly better, break it.
12. Checklist (paste into your code review template)
Before merging any non-trivial class:
- SRP — does this class have more than one reason to change? If yes, split.
- OCP — does adding a new variant require editing this code? If yes, introduce a strategy.
- LSP — does every implementation honour every promise the interface makes? If even one throws
NotSupportedException, the contract is wrong. - ISP — is any implementer forced to handle methods it doesn''t care about? If yes, split the interface.
- DIP — does this class talk to infrastructure (DB, HTTP, clock, IO) via
new? If yes, inject an abstraction.
Five questions. Run them at PR time, not at design time. Most of the time, the answers are "yes, that''s fine." Sometimes one will catch the future bug that would have taken two days to fix.
13. Closing — SOLID is a default, not a religion
The best teams I''ve worked with use SOLID the way good writers use grammar rules: they know them deeply, they follow them most of the time, and they break them on purpose when it makes the code better. They don''t argue about whether a class is "really" SRP-compliant; they argue about whether the next change will be easy.
Three habits that make SOLID actually pay off:
- Refactor in small steps. Apply one principle at a time. Don''t try to rewrite a class to be "SOLID-compliant" — you''ll over-engineer it.
- Let pain drive design. When a test is hard to write, that''s a DIP signal. When a change touches five files, that''s an SRP signal. Listen to the friction.
- Read your own code six months later. That''s the only honest test of whether SOLID is helping. If your past self made the right calls, you''ll see it.
Apply SOLID by default. Break it when you have a real reason. Document the reason. That''s the whole game.
Further reading
- Robert C. Martin — Clean Architecture — the canonical source.
- Mark Seemann — Dependency Injection Principles, Practices, and Patterns — DIP done right in .NET.
- Vladimir Khorikov — Unit Testing Principles — pairs perfectly with SOLID for .NET teams.
- Refactoring (Martin Fowler) — the toolkit you''ll need to apply SOLID to existing code.
Got a real class you can''t decide whether to split? Email randhir.jassal@gmail.com with the code and I''ll show you which principle is screaming the loudest.
Get the next issue
A short, curated email with the newest posts and questions.