Skip to content
Back to blog
.NETArchitectureSOLIDClean CodeDesign Patterns

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

July 4, 202614 min read

SOLID is five principles for writing object-oriented code that's easy to extend without breaking existing behavior. They were named by Robert Martin and have become the foundational vocabulary of software design discussions. Every developer claims to know them, but in practice most codebases violate them quietly: giant classes that do everything, switch statements on type tags, interfaces with methods the implementer doesn't need, high-level modules that reach down and import low-level ones directly.

This article goes beyond the acronym. Each principle gets a real before-and-after in C# — not an abstract Animal/Dog example, but patterns you'd actually encounter in a backend API. Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — what they mean, how they're violated, and what the code looks like when you fix them.

Single Responsibility Principle

A class should have one reason to change. Not one method, not one field — one reason. If two different stakeholders could request changes to the same class for different reasons, it has too many responsibilities. An OrderService that handles order placement, email notifications, invoice generation, and inventory updates changes when the business team changes order logic, when the email team changes templates, and when the finance team changes invoice formats. Three teams, three reasons, one class — a recipe for merge conflicts and hidden bugs.

The fix is not to create a hundred tiny classes for every line of code. It's to group behavior by who has reason to change it. Order placement logic changes with order business rules. Email logic changes with communication strategy. Invoice logic changes with finance requirements. Each belongs in its own service.

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.
Before
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}
After
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 but closed for modification. When you add new behavior, you should not need to edit existing, tested code. The classic violation is a switch statement on a type tag that grows every time a new type is added — each addition risks breaking every existing case.

The fix is polymorphism: define an abstraction, implement it for each variant, and dispatch based on type through the type system, not a switch. New variants extend the system without touching existing implementations.

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.
Before
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}
After
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 objects of type T are expected, without changing the correctness of the program. In plain English: subtypes must keep the promises of their base type. If you override a method and throw an exception the base class never threw, or silently skip doing what the base class promised, you've violated LSP.

The violation shows up as 'if this is a FooBar, do something different' type checks on instances — code that should be polymorphic has devolved into explicit type inspection because the subtype broke the abstraction.

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.
Before
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
After
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 don't use. A fat interface — one with many methods — forces every implementer to implement all of them, even the ones irrelevant to their use case. Implementers either throw NotImplementedException (an LSP violation) or leave them as empty stubs.

The fix is narrow, focused interfaces. Each client depends only on the methods it actually calls. A query handler doesn't need write methods. An audit logger doesn't need read methods. Split by what clients actually need, not by what the domain object supports.

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.
Before
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}
After
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. This is the principle behind dependency injection — instead of a business service creating its own database connection or HTTP client, those dependencies are injected via interfaces. The business service depends on an interface (abstraction) that the database or HTTP client implements (detail).

DIP doesn't just mean 'use interfaces.' The key word is inversion: the high-level module (OrderService) owns and defines the interface (IOrderRepository). The low-level module (EfOrderRepository) implements it. The dependency arrow points from the implementation toward the business layer, not the other way around.

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.
Before
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}
After
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.

Key takeaway

Share:

SOLID principles are not rules to follow mechanically — they're heuristics for recognizing when code is heading toward a bad place. A class with five reasons to change is heading toward a maintenance nightmare. A switch statement that grows with every new business type is heading toward a fragile, coupled system. An interface that forces implementers to throw NotImplementedException is heading toward subtle runtime bugs.

In practice: Single Responsibility keeps classes focused. Open/Closed makes extension safe. Liskov keeps polymorphism trustworthy. Interface Segregation keeps contracts honest. Dependency Inversion keeps business logic testable. Apply them when you feel pain — when adding a feature requires editing unrelated code, when tests require a real database, when a switch statement is growing. That pain is the principles telling you something needs to change.

Related Articles

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

Read

Artificial intelligence is not here to replace .NET developers. It is here to extend what they can build. The fundamenta

Read
Event SourcingCQRS

Event Sourcing and CQRS are two patterns that often appear together in microservices and domain-driven design — but they

Read

Explore this topic

Keep learning

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