Skip to content

SOLID Principles in .NET: What They Mean and Why They Matter

Core Concept LearningJuly 4, 20268 min readUpdated July 21, 2026

SOLID is five design heuristics for object-oriented code that must change safely. Robert Martin popularized the acronym; the ideas are older than the name. They are not laws of physics — they are vocabulary for recognizing when a class has too many reasons to change, when extension requires editing tested code, or when a subtype silently breaks a contract.

This guide treats SOLID as a knowledge map: each letter is a precise claim, a common violation, and a C# fix you would see in a backend API. We also place SOLID on top of OOP pillars and under Clean Architecture layers — because principles without a place in the dependency graph become slogans. Benefits like “easy testing” and “low coupling” are consequences, not a sixth principle.

See how these dependency rules scale into Clean Architecture in .NET. For a broader toolbox around service boundaries, compare the microservices design patterns.

Knowledge map: five principles → design consequences
Knowledge map: five principles → design consequences

SOLID as a knowledge map

Read the five letters as related constraints, not a checklist to tattoo on every class. SRP limits why a module changes. OCP limits how you add behavior without rewriting what already works. LSP and ISP protect the honesty of type hierarchies and interfaces. DIP flips dependency arrows so policy (business rules) does not import volatile details (SQL, SMTP, Stripe).

A useful philosophy: optimize for changeability under incomplete foresight. You cannot predict every feature, but you can refuse designs that make the next feature touch five unrelated teams’ code. Premature abstraction is also a cost — apply a principle when you feel the pain it names, not because an infographic listed five icons.

Quick reference

  • Definition: SOLID = five OO heuristics for safe change — not a framework, not Clean Architecture itself.
  • Consequence ≠ principle: clean tests and low coupling follow from applying the five; they are not substitutes for them.
  • Common error: treating SOLID as mandatory on every DTO and one-liner helper.
  • Heuristic: if adding a feature forces edits in many unrelated modules, name which letter you violated.
  • Interview angle: explain one principle with a violation and a fix — slogans without mechanism fail design reviews.

Remember this

Applying a principle before feeling the pain it names is premature abstraction, not discipline — a feature that forces edits across five unrelated modules names exactly which letter got violated.

Single Responsibility Principle

A class should have one reason to change — not “one method,” not “one field.” If two different stakeholders could request changes for different reasons, the class is overloaded. An OrderService that places orders, sends email, writes invoices, and adjusts inventory changes when business, notifications, finance, or warehouse rules change. That is four axes of change in one type.

The mechanism: split along the axis of change, then reconnect with events or orchestration. The philosophy is cohesion under ownership — not microscopic classes for vanity. A pure orchestrator that only sequences steps still has one responsibility: orchestration.

SRP: one class, one axis of change (not “one method”)
SRP: one class, one axis of change (not “one method”)

Quick reference

  • Ask: 'What would make me change this class?' Multiple different answers = multiple responsibilities.
  • A class with more than ~200 lines is a warning sign — not a rule, but worth checking.
  • SRP at the module level too: a namespace/folder should have one reason to exist.
  • Domain events (OrderPlacedEvent) are a great way to separate the core action from side effects.
  • Services that 'just orchestrate' are fine — the responsibility is orchestration, not implementation.
OrderService doing too much — multiple reasons to change
1public class OrderService2{3    private readonly AppDbContext _db;4    private readonly SmtpClient _smtp;5    private readonly PdfGenerator _pdf;6 7    // Business logic + email + invoice + inventory — all mixed8    public async Task PlaceOrderAsync(CreateOrderDto dto)9    {10        // Business logic11        var product = await _db.Products.FindAsync(dto.ProductId)12            ?? throw new NotFoundException("Product not found");13        if (product.Stock < dto.Quantity)14            throw new BusinessException("Insufficient stock");15        product.Stock -= dto.Quantity;16 17        var order = new Order { ProductId = product.Id, Total = product.Price * dto.Quantity };18        _db.Orders.Add(order);19        await _db.SaveChangesAsync();20 21        // Email logic — belongs to notifications team22        var message = new MailMessage("noreply@example.com", dto.CustomerEmail);23        message.Subject = "Order Confirmed";24        message.Body = $"Your order #{order.Id} for {product.Name} is confirmed.";25        await _smtp.SendMailAsync(message);26 27        // Invoice logic — belongs to finance team28        var invoice = _pdf.Generate(order);29        await File.WriteAllBytesAsync($"invoices/{order.Id}.pdf", invoice);30    }31}
Each service has one reason to change
1// Order placement — changes with business rules2public class OrderService3{4    private readonly IOrderRepository _orders;5    private readonly IProductRepository _products;6    private readonly IPublisher _publisher;7 8    public async Task<Guid> PlaceOrderAsync(CreateOrderDto dto, CancellationToken ct)9    {10        var product = await _products.GetAsync(dto.ProductId, ct)11            ?? throw new NotFoundException("Product not found");12 13        product.ReserveStock(dto.Quantity); // domain logic14 15        var order = Order.Create(product, dto.Quantity, dto.CustomerEmail);16        await _orders.AddAsync(order, ct);17 18        await _publisher.PublishAsync(new OrderPlacedEvent(order), ct);19        return order.Id;20    }21}22 23// Email logic — changes with communication strategy24public class OrderNotificationHandler : INotificationHandler<OrderPlacedEvent>25{26    private readonly IEmailSender _email;27 28    public async Task Handle(OrderPlacedEvent evt, CancellationToken ct)29        => await _email.SendAsync(evt.CustomerEmail, "Order Confirmed",30            $"Your order #{evt.OrderId} is confirmed.", ct);31}32 33// Invoice logic — changes with finance requirements34public class InvoiceHandler : INotificationHandler<OrderPlacedEvent>35{36    private readonly IInvoiceGenerator _invoices;37    private readonly IFileStorage _storage;38 39    public async Task Handle(OrderPlacedEvent evt, CancellationToken ct)40    {41        var pdf = await _invoices.GenerateAsync(evt.OrderId, ct);42        await _storage.SaveAsync($"invoices/{evt.OrderId}.pdf", pdf, ct);43    }44}

Remember this

Group behavior by who has reason to change it, not by what data it touches. Use domain events to separate core business actions from notification and reporting side effects.

Open/Closed Principle

Software entities should be open for extension and closed for modification: new behavior should not require editing already-tested code. The classic violation is a growing switch on a type tag — every new case risks every old case.

Mechanism: introduce a stable abstraction (strategy, plugin, pipeline step) and add variants as new types. Truth check: OCP is not “never edit files.” Bug fixes and API redesigns still touch code. OCP targets the predicted dimension of variation (discount kinds, payment providers). Abstracting everything “just in case” is a different — and expensive — mistake.

OCP: new behavior = new type; old tested code stays closed
OCP: new behavior = new type; old tested code stays closed

Quick reference

  • OCP is usually achieved through the Strategy pattern (swappable algorithms) or Template Method (fixed skeleton, swappable steps).
  • In .NET: interfaces, abstract classes, and DI container registration enable OCP at the service level.
  • Use OCP when you can predict the dimension of change. Not every class needs to be open — premature abstraction is a real cost.
  • The Decorator pattern extends behavior without modifying the original: wrap IEmailSender to add logging, retry, rate limiting.
  • Configuration-driven extension: adding a new discount via config (not code) is a form of OCP.
Switch statement grows with every new discount type
1public class PricingService2{3    public decimal ApplyDiscount(Order order, string discountType)4    {5        // Every new discount type requires editing this method6        return discountType switch7        {8            "percentage" => order.Total * 0.9m,9            "fixed"      => order.Total - 10m,10            "bogo"       => order.Total * 0.5m,11            // New type added here breaks this existing tested method12            _ => order.Total13        };14    }15}
Open for extension — new discounts without editing existing code
1// Abstraction — closed for modification2public interface IDiscountStrategy3{4    decimal Apply(decimal total);5}6 7// Each implementation is independent — adding one doesn't touch others8public class PercentageDiscount(decimal percent) : IDiscountStrategy9{10    public decimal Apply(decimal total) => total * (1 - percent / 100);11}12 13public class FixedDiscount(decimal amount) : IDiscountStrategy14{15    public decimal Apply(decimal total) => Math.Max(0, total - amount);16}17 18public class BuyOneGetOneDiscount : IDiscountStrategy19{20    public decimal Apply(decimal total) => total * 0.5m;21}22 23// Pricing service — never changes when new discount types are added24public class PricingService25{26    public decimal ApplyDiscount(Order order, IDiscountStrategy discount)27        => discount.Apply(order.Total);28}29 30// Add a new discount type: create a new class, zero existing code changes31public class LoyaltyDiscount(int loyaltyPoints) : IDiscountStrategy32{33    public decimal Apply(decimal total) => total * (1 - Math.Min(loyaltyPoints, 1000) / 10000m);34}

Remember this

Replace switch-on-type with polymorphism. Define an interface for the variation point, implement a class per variant, dispatch through the type system.

Liskov Substitution Principle

If S is a subtype of T, objects of type S must be usable wherever T is expected without breaking the program’s correctness. Subtypes must keep the promises of the base contract: no stronger preconditions, no weaker postconditions, no surprise exceptions for methods the base said were safe.

A ReadOnlyRepository that implements IRepository and throws on Add is a textbook violation — callers that trust IRepository are lying to themselves. Mechanism: narrow the type (ISP) so you never advertise capabilities you cannot honor. Symptom: is / as checks scattered through polymorphic code.

Zoom into one Liskov substitution failure in a payment strategy
Zoom into one Liskov substitution failure in a payment strategy

Quick reference

  • LSP is often violated by throwing NotSupportedException in overrides — a code smell.
  • Check your overrides: do they strengthen preconditions (require more than base)? That's a violation.
  • Do they weaken postconditions (promise less than base)? That's a violation.
  • Symptom: instanceof / is checks in polymorphic code. The caller shouldn't need to know the concrete type.
  • Interface segregation (next principle) often solves LSP violations by splitting over-broad contracts.
ReadOnlyRepository breaks the IRepository contract
1public interface IRepository<T>2{3    Task<T?> GetAsync(Guid id);4    Task AddAsync(T entity);5    Task UpdateAsync(T entity);6    Task DeleteAsync(Guid id);7}8 9// Violates LSP — callers expect IRepository<T> to support writes10public class ReadOnlyRepository<T> : IRepository<T>11{12    public Task<T?> GetAsync(Guid id) => ... // fine13 14    public Task AddAsync(T entity) =>15        throw new NotSupportedException("Read-only!"); // breaks the contract!16 17    public Task UpdateAsync(T entity) =>18        throw new NotSupportedException("Read-only!");19 20    public Task DeleteAsync(Guid id) =>21        throw new NotSupportedException("Read-only!");22}23 24// Caller can't safely use IRepository<T> — must check actual type25if (repo is not ReadOnlyRepository<Product>)26    await repo.AddAsync(product); // type checking = LSP violation symptom
Segregated interfaces — no impossible promises
1// Split the interface — callers depend only on what they need2public interface IReadRepository<T>3{4    Task<T?> GetAsync(Guid id);5    Task<IReadOnlyList<T>> ListAsync();6}7 8public interface IWriteRepository<T> : IReadRepository<T>9{10    Task AddAsync(T entity);11    Task UpdateAsync(T entity);12    Task DeleteAsync(Guid id);13}14 15// Read-only repository — no impossible promises16public class CachedProductRepository : IReadRepository<Product>17{18    private readonly IMemoryCache _cache;19    private readonly AppDbContext _db;20 21    public async Task<Product?> GetAsync(Guid id)22    {23        return await _cache.GetOrCreateAsync($"product:{id}",24            _ => _db.Products.FindAsync(id).AsTask());25    }26}27 28// Write repository — full contract29public class EfProductRepository : IWriteRepository<Product> { ... }30 31// Query handler depends on read interface — LSP safe32public class GetProductHandler(IReadRepository<Product> repo)33{34    public Task<Product?> Handle(GetProductQuery q, CancellationToken ct)35        => repo.GetAsync(q.Id);36}

Remember this

Subtypes must honor the contracts of their base type. If a subtype can't fulfill a method, the interface is too broad — split it.

Interface Segregation Principle

Clients should not be forced to depend on interfaces they do not use. A fat interface forces every implementer to stub irrelevant methods — often with NotSupportedException, which is also an LSP failure. Mechanism: split by caller role (reader, writer, authenticator), not by “everything this aggregate can do.”

Philosophy: an interface is a promise to a client, not a dump of a class’s public surface. Query handlers should not compile against write APIs they never call — that coupling slows change and bloated mocks.

ISP: fat interface → role interfaces clients actually need
ISP: fat interface → role interfaces clients actually need

Quick reference

  • A good interface is small enough that every implementer implements every method without stubs.
  • ISP complements SRP: SRP keeps classes focused, ISP keeps interfaces focused.
  • In .NET with DI, narrow interfaces make testing trivial — mock only the methods the class under test calls.
  • Role interfaces: define interfaces based on the role a class plays for its client, not on what it is.
  • Marker interfaces (IDisposable, ICloneable) are legitimate — they express a capability, not a method set.
Fat interface — every implementer implements everything
1// Fat interface — forces implementers to provide methods they don't need2public interface IUserService3{4    Task<User?> GetUserAsync(Guid id);5    Task<List<User>> SearchUsersAsync(string query);6    Task CreateUserAsync(CreateUserDto dto);7    Task UpdateUserAsync(UpdateUserDto dto);8    Task DeleteUserAsync(Guid id);9    Task<bool> ValidateCredentialsAsync(string email, string password);10    Task SendPasswordResetEmailAsync(string email);11    Task<List<AuditLog>> GetAuditLogsAsync(Guid userId);12    Task ExportToCsvAsync(Stream output);13}14 15// External user service only supports read — must stub everything else16public class ExternalUserService : IUserService17{18    public Task<User?> GetUserAsync(Guid id) => ... // implemented19    public Task CreateUserAsync(CreateUserDto dto) =>20        throw new NotSupportedException(); // forced stub21    public Task DeleteUserAsync(Guid id) =>22        throw new NotSupportedException(); // forced stub23    // ... 6 more stubs24}
Segregated interfaces — each client gets exactly what it needs
1// Narrow, focused interfaces2public interface IUserReader3{4    Task<User?> GetAsync(Guid id);5    Task<List<User>> SearchAsync(string query);6}7 8public interface IUserWriter9{10    Task CreateAsync(CreateUserDto dto);11    Task UpdateAsync(UpdateUserDto dto);12    Task DeleteAsync(Guid id);13}14 15public interface IAuthenticator16{17    Task<bool> ValidateCredentialsAsync(string email, string password);18    Task SendPasswordResetAsync(string email);19}20 21public interface IUserAuditLog22{23    Task<List<AuditLog>> GetLogsAsync(Guid userId);24}25 26// External service only implements what it supports — no forced stubs27public class ExternalUserService : IUserReader, IAuthenticator28{29    public Task<User?> GetAsync(Guid id) => ...30    public Task<List<User>> SearchAsync(string query) => ...31    public Task<bool> ValidateCredentialsAsync(string email, string password) => ...32    public Task SendPasswordResetAsync(string email) => ...33}34 35// Each handler depends on the minimum interface it needs36public class GetUserHandler(IUserReader users) { ... }37public class CreateUserHandler(IUserWriter users) { ... }38public class LoginHandler(IAuthenticator auth) { ... }

Remember this

Define interfaces from the caller's perspective. If an implementer has to throw NotImplementedException, the interface is too fat — split it.

Dependency Inversion Principle

High-level modules should not depend on low-level modules; both should depend on abstractions. The inversion is ownership: OrderService (policy) defines IPayment / IOrderRepository; CreditCardPayment and EfOrderRepository (details) implement them. Dependency arrows point inward toward policy — not from business code into SQL drivers.

Infographics often show Order → Payment <<interface>> → CreditCard | PayPal. That picture is right when the interface is stable and owned by the use case. It is wrong when “use an interface” means a low-level MySqlRepository type still imported directly by Order — that is decoration, not inversion. DIP is what makes constructor injection meaningful; DI without ports is just passing objects around.

DIP knowledge map: Order depends on Payment abstraction
DIP knowledge map: Order depends on Payment abstraction

Quick reference

  • DIP is what makes dependency injection meaningful — DI without interfaces is just passing objects around.
  • The interface belongs to the high-level module, not the low-level one. This is the 'inversion' in the name.
  • AddScoped: one instance per HTTP request. AddTransient: new instance every time. AddSingleton: one for the app lifetime.
  • Constructor injection is the standard in .NET — inject via constructor, not property or method.
  • Test doubles: use FakeXxx for in-memory implementations in unit tests. Use Mock<IXxx> for verifying interactions.
  • DIP enables the Clean Architecture layers: Application defines interfaces, Infrastructure implements them.
High-level depends on low-level — direct coupling
1// OrderService directly depends on EF Core (low-level detail)2public class OrderService3{4    // Direct instantiation — can't test, can't swap implementation5    private readonly AppDbContext _db = new AppDbContext(6        new DbContextOptionsBuilder<AppDbContext>()7            .UseSqlServer("Server=prod;Database=orders;...")8            .Options);9 10    private readonly SmtpClient _smtp = new SmtpClient("smtp.example.com");11 12    public async Task PlaceOrderAsync(CreateOrderDto dto)13    {14        // Directly uses EF Core, SMTP — tightly coupled to infrastructure15        var order = new Order { ... };16        _db.Orders.Add(order);17        await _db.SaveChangesAsync();18        await _smtp.SendMailAsync(new MailMessage(...));19    }20}
Dependency inversion — abstractions owned by the business layer
1// Abstraction defined by high-level business layer2// (NOT by the infrastructure that implements it)3public interface IOrderRepository4{5    Task<Order?> GetAsync(Guid id, CancellationToken ct = default);6    Task AddAsync(Order order, CancellationToken ct = default);7}8 9public interface IEmailSender10{11    Task SendAsync(string to, string subject, string body, CancellationToken ct = default);12}13 14// OrderService depends on abstractions, not concrete EF Core or SMTP15public class OrderService16{17    private readonly IOrderRepository _orders;18    private readonly IEmailSender _email;19 20    // Dependencies injected — OrderService doesn't know about EF or SMTP21    public OrderService(IOrderRepository orders, IEmailSender email)22    {23        _orders = orders;24        _email = email;25    }26 27    public async Task PlaceOrderAsync(CreateOrderDto dto, CancellationToken ct)28    {29        var order = Order.Create(dto);30        await _orders.AddAsync(order, ct);31        await _email.SendAsync(dto.CustomerEmail, "Order Confirmed", "...", ct);32    }33}34 35// Low-level implementations — depend on the interfaces (dependency points inward)36public class EfOrderRepository(AppDbContext db) : IOrderRepository { ... }37public class SendGridEmailSender(IOptions<SendGridSettings> opts) : IEmailSender { ... }38 39// Registration in Program.cs40builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();41builder.Services.AddTransient<IEmailSender, SendGridEmailSender>();42 43// Unit test — swap implementations trivially44var orders = new FakeOrderRepository();45var email = new FakeEmailSender();46var sut = new OrderService(orders, email);

Remember this

High-level business modules define interfaces; low-level infrastructure implements them. Inject via constructor. This is what makes your business logic testable without a database.

OOP pillars under SOLID

SOLID assumes classical OOP machinery. A class is a blueprint; an object is an instance with state and behavior. Encapsulation keeps invariants inside the type so callers cannot corrupt them. Inheritance and polymorphism let you vary behavior behind a shared contract. Abstraction hides irrelevant detail so callers program to roles, not storage engines.

Truth: SOLID does not replace these pillars — it disciplines them. Inheritance without LSP becomes a trap. Interfaces without ISP become fat lies. Polymorphism without OCP collapses into switches. If your language style is more functional, map the same ideas (cohesion, extension points, honest contracts, dependency direction) without forcing class hierarchies.

OOP pillars: SOLID assumes these building blocks
OOP pillars: SOLID assumes these building blocks

Quick reference

  • Class/object: shared shape, many instances — not a substitute for modules.
  • Encapsulation: public contract vs private representation; protects invariants.
  • Inheritance: reuse and subtype relationships — dangerous without LSP.
  • Polymorphism: same call site, different behavior — enables OCP.
  • Abstraction: essential vs accidental detail — feeds DIP ports.
  • When OO is wrong for the problem, do not force SOLID theater — keep the heuristics.

Remember this

Inheritance without LSP becomes a trap, interfaces without ISP become fat lies — SOLID disciplines the OOP pillars, and forcing class hierarchies where the language style is functional keeps the theater without the benefit.

Layers: where SOLID meets Clean Architecture

Infographics stack Presentation → Application → Domain → Infrastructure. That stack is a dependency rule, not magic boxes: outer layers may know inner ones; domain must not import EF, HTTP, or SMTP. DIP lives naturally here — Application defines ports; Infrastructure adapters implement them; Presentation only talks to Application use cases.

SOLID without layers still helps a single project. Layers without SOLID still tangle use cases with SQL. Together: SRP keeps handlers focused, OCP/DIP let you swap payment and persistence, ISP keeps ports small, LSP keeps fakes honest in tests. For the full .NET walkthrough of projects and CQRS, see the Clean Architecture guide — this section is the map, not a second copy of that article.

Layers: Presentation → Application → Domain ← Infrastructure
Layers: Presentation → Application → Domain ← Infrastructure

Quick reference

  • Presentation: UI/API entry — depends on Application, not Domain details.
  • Application: use cases + ports (interfaces) — depends on Domain.
  • Domain: entities and rules — zero framework dependencies.
  • Infrastructure: DB, email, gateways — implements Application ports.
  • Product default ≠ theorem: some apps collapse layers until pain appears — that is a trade-off, not a free pass to cyclic spaghetti.
  • Practice: draw arrows for one feature; any arrow from Domain to Infrastructure fails the rule.

Remember this

Any arrow from Domain to Infrastructure fails the dependency rule — layers without SOLID still tangle use cases with SQL, and SOLID without layers still helps but only inside a single project.

When the pain names the principle

Do not apply all five to every file. Match the symptom: many reasons to change → SRP; must edit a switch to add a variant → OCP; subtypes throw or callers type-check → LSP/ISP; tests need a real database for a business rule → DIP.

Philosophy for reviews: ask what property you are protecting (cohesion, extension safety, contract honesty, testability). If nobody can state the property, the SOLID citation is theater.

When SOLID pain appears — apply the matching principle
When SOLID pain appears — apply the matching principle

Quick reference

  • Pain → principle is more reliable than principle → refactor everything.
  • Over-abstraction has a cost — YAGNI still applies.
  • Prefer one real port and one fake over five empty interfaces.
  • Document the variation point you opened (payments, discounts) so the team knows what OCP was for.

Remember this

Pain naming the principle beats principle-first refactoring — one real port and one fake beats five empty interfaces, and a SOLID citation nobody can tie to a protected property is theater.

Key takeaway

SOLID is a compact language for safe change in object-oriented systems: one reason to change, extend without rewrite, honest subtypes and interfaces, and dependencies that point toward policy. The payoff — cleaner tests, lower coupling, maintainability — follows when the principles are applied where pain appears, not where an acronym wants stickers.

Practice (25 min): Take one “god” service in a sample API. List its reasons to change (SRP). Extract one side effect behind an interface you own (DIP). Add a second fake implementation for a unit test. If you cannot name which letter you applied, the refactor was random.

Share:

Related Articles

In enterprise .NET applications, traditional Create-Read-Update-Delete (CRUD) architectures suffer when handling complex

Read

Shipping faster in .NET is less about memorizing NuGet packages and more about knowing which job needs a tool: identity,

Read

Most .NET projects start clean and become entangled within six months. Controllers call repositories that call other ser

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.