Waqas Ahmad — Software Architect & Technical Consultant - Available USA, Europe, Global

Waqas Ahmad — Software Architect & Technical Consultant

Specializing in

Distributed Systems

.NET ArchitectureCloud-Native ArchitectureAzure Cloud EngineeringAPI ArchitectureMicroservices ArchitectureEvent-Driven ArchitectureDatabase Design & Optimization

👋 Hi, I'm Waqas — a Software Architect and Technical Consultant specializing in .NET, Azure, microservices, and API-first system design..
I help companies build reliable, maintainable, and high-performance backend platforms that scale.

Experienced across engineering ecosystems shaped by Microsoft, the Cloud Native Computing Foundation, and the Apache Software Foundation.

Available for remote consulting (USA, Europe, Global) — flexible across EST, PST, GMT & CET.

services
Article

SOLID Principles in Practice: .NET Examples

SOLID in .NET: SRP, OCP, Liskov, ISP, DIP with before/after code and practices.

services
Read the article

Introduction

This guidance is relevant when you are designing or refactoring object-oriented code in .NET and care about testability, change isolation, and clear dependencies. It breaks down when the codebase is procedural, short-lived, or when the team has no appetite for interfaces and DI. I’ve applied SOLID in contexts where multiple developers touch the same domain, where tests and swapping implementations matter, and where the code is expected to evolve (as of 2026).

Code that does too much in one place or depends on concrete types is hard to test and hard to change. This article covers SOLID in practice with .NET: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—each with before/after C# examples, step-by-step refactors, and when to relax them. For architects and tech leads, applying SOLID where multiple developers touch the same domain keeps change isolated and tests easier; we explain each principle and how it fits real applications.

If you are new to SOLID, start with Topics covered and SOLID at a glance.

For more on .NET and clean code, see the .NET Architecture resource.

Decision Context

  • System scale: Codebases from a few thousand to hundreds of thousands of lines; multiple services or modules. SOLID pays off when multiple people change the same area.
  • Team size: One developer can benefit; the gains multiply when several developers work on the same domain and need clear boundaries and test doubles.
  • Time / budget pressure: Applying SOLID in greenfield is easier than retrofitting; refactors take time. I’ve introduced SOLID gradually (DIP and SRP first) when under delivery pressure.
  • Technical constraints: .NET and C#; DI container and interface-based design. Not tied to a specific framework version (as of 2026, .NET 6+ is the typical baseline).
  • Non-goals: Not optimizing for minimal lines of code or for one-off scripts; not claiming SOLID is always the right choice for every class.

What is SOLID and why it matters

SOLID is a set of five design principles introduced by Robert C. Martin (Uncle Bob). They guide how you structure classes and dependencies so that:

  • Change is local (one reason to change per class).
  • Extension happens by adding new code, not by editing existing code.
  • Subtypes can replace base types without breaking callers.
  • Clients depend only on what they use (small interfaces).
  • High-level code does not depend on low-level details (abstractions).

Why it matters: Code that follows SOLID is easier to test (small, focused units; injectable dependencies), extend (new behavior via new types), and maintain (changes are isolated). Violations often lead to large, brittle classes and tight coupling.


SOLID at a glance

Principle One-line idea .NET approach
Single Responsibility One reason to change per class Split validation, persistence, notification into separate classes
Open/Closed Open for extension, closed for modification Use interfaces and new implementations; avoid switch on type
Liskov Substitution Subtypes substitutable for base Do not weaken preconditions or strengthen postconditions; no surprise throws
Interface Segregation Clients do not depend on unused methods Small interfaces: IReadOrders, IWriteOrders instead of one fat interface
Dependency Inversion Depend on abstractions Depend on IOrderRepository; inject implementation via DI

Single Responsibility (SRP)

What it is and why it matters

Single Responsibility: A class should have one reason to change. If one class does both “validate order” and “send confirmation email”, then a change in validation rules or in email logic forces you to touch the same class. That increases the risk of regressions and makes testing harder. Split so that each class has one clear responsibility: one for validation, one for persistence, one for notification.

Before (multiple responsibilities)

public class OrderService
{
    public void PlaceOrder(Order order)
    {
        if (order.Total <= 0) throw new ArgumentException("Invalid total");
        if (order.Items.Count == 0) throw new ArgumentException("No items");
        _db.Orders.Add(order);
        _db.SaveChanges();
        _smtp.Send(order.CustomerEmail, "Order confirmed", $"Order {order.Id} placed.");
    }
}

Problems: Validation, persistence, and notification are in one class. Changing validation rules, DB access, or email logic all touch OrderService.

After (single responsibility)

public class OrderValidator
{
    public void Validate(Order order)
    {
        if (order.Total <= 0) throw new ArgumentException("Invalid total");
        if (order.Items.Count == 0) throw new ArgumentException("No items");
    }
}

public class OrderRepository : IOrderRepository
{
    public void Add(Order order) { /* persist */ }
}

public class OrderNotificationService : IOrderNotificationService
{
    public void SendConfirmation(Order order) { /* email */ }
}

public class OrderService
{
    private readonly IOrderValidator _validator;
    private readonly IOrderRepository _repo;
    private readonly IOrderNotificationService _notification;

    public OrderService(IOrderValidator v, IOrderRepository r, IOrderNotificationService n)
    {
        _validator = v; _repo = r; _notification = n;
    }

    public void PlaceOrder(Order order)
    {
        _validator.Validate(order);
        _repo.Add(order);
        _notification.SendConfirmation(order);
    }
}

How this fits together: OrderService orchestrates; it has one job (place order flow). Validation, persistence, and notification each live in their own class with one reason to change. You can test OrderValidator in isolation, swap email for SMS by replacing IOrderNotificationService, and change persistence without touching validation.


Open/Closed (OCP)

What it is and why it matters

Open/Closed: Software should be open for extension (add new behavior) but closed for modification (avoid changing existing code). Add new behavior by adding new code (e.g. new class, new strategy) rather than editing existing classes. Use interfaces and dependency injection so new implementations can be plugged in without changing callers.

Before (modification required for each new type)

public decimal GetDiscount(Order order)
{
    if (order.CustomerType == "Standard") return order.Total * 0m;
    if (order.CustomerType == "Premium") return order.Total * 0.1m;
    if (order.CustomerType == "Gold") return order.Total * 0.2m;
    throw new NotSupportedException();
}

Problems: Adding a new customer type (e.g. “Platinum”) forces you to modify this method and retest all branches.

After (extension via new implementations)

public interface IDiscountStrategy
{
    decimal GetDiscount(Order order);
}

public class StandardDiscountStrategy : IDiscountStrategy
{
    public decimal GetDiscount(Order order) => 0m;
}

public class PremiumDiscountStrategy : IDiscountStrategy
{
    public decimal GetDiscount(Order order) => order.Total * 0.1m;
}

public class GoldDiscountStrategy : IDiscountStrategy
{
    public decimal GetDiscount(Order order) => order.Total * 0.2m;
}

// New type: add new class, register in DI; no change to existing code
public class PlatinumDiscountStrategy : IDiscountStrategy
{
    public decimal GetDiscount(Order order) => order.Total * 0.25m;
}

public class OrderDiscountService
{
    private readonly IReadOnlyDictionary<string, IDiscountStrategy> _strategies;
    public OrderDiscountService(IEnumerable<IDiscountStrategy> strategies) { /* build map by type */ }
    public decimal GetDiscount(Order order) => _strategies[order.CustomerType].GetDiscount(order);
}

How this fits together: New behavior = new class implementing IDiscountStrategy and registered in DI. Existing OrderDiscountService and existing strategies are unchanged. You are extending by adding code, not modifying existing code.


Liskov Substitution (LSP)

What it is and why it matters

Liskov Substitution: Subtypes must be substitutable for their base types. If B extends A, any code that expects A should work with B without knowing the difference. Do not weaken preconditions (require more from callers) or strengthen postconditions (guarantee less). Do not throw in cases the base type does not (or change semantics so callers break).

Violation example (Rectangle and Square)

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.Height = value; base.Width = value; } }
}

Problem: Code that expects Rectangle might set Width and Height independently (e.g. r.Width = 4; r.Height = 5;). With Square, setting one changes the other, so Area and behavior surprise the caller. Substitution breaks.

Better design (no inheritance that breaks contract)

  • Do not make Square inherit Rectangle if it changes the contract (independent width/height).
  • Or expose a common interface (e.g. IShape with Area) and have Rectangle and Square as separate implementations. Callers depend on IShape; both are substitutable for that contract.
public interface IShape { int Area { get; } }
public class Rectangle : IShape { public int Width { get; set; } public int Height { get; set; } public int Area => Width * Height; }
public class Square : IShape { public int Side { get; set; } public int Area => Side * Side; }

How this fits together: Subtypes must honor the contract of the base (or interface). If the base allows setting width and height independently, a subtype cannot silently couple them. Either do not inherit, or use an interface that only exposes what is truly common (e.g. Area).


Interface Segregation (ISP)

What it is and why it matters

Interface Segregation: Clients should not depend on interfaces they do not use. Prefer small, focused interfaces (e.g. IReadOrders, IWriteOrders) over one large interface so that implementers are not forced to provide unused methods and callers depend only on what they need.

Before (fat interface)

public interface IOrderService
{
    Order GetById(int id);
    IReadOnlyList<Order> GetByCustomer(string customerId);
    void Add(Order order);
    void Update(Order order);
    void Delete(int id);
}

// Reporter only needs read; but must depend on full IOrderService
public class OrderReporter
{
    private readonly IOrderService _orders;
    public OrderReporter(IOrderService orders) => _orders = orders;
    public Report Generate() => new Report(_orders.GetByCustomer("C1"));
}

Problems: OrderReporter only needs read operations but depends on IOrderService with write methods. Any implementation (e.g. stub for tests) must implement all methods. Changes to write contract can affect read-only clients.

After (segregated interfaces)

public interface IReadOrders
{
    Order GetById(int id);
    IReadOnlyList<Order> GetByCustomer(string customerId);
}

public interface IWriteOrders
{
    void Add(Order order);
    void Update(Order order);
    void Delete(int id);
}

public interface IOrderService : IReadOrders, IWriteOrders { }

public class OrderReporter
{
    private readonly IReadOrders _orders;
    public OrderReporter(IReadOrders orders) => _orders = orders;
    public Report Generate() => new Report(_orders.GetByCustomer("C1"));
}

How this fits together: Callers that only read depend on IReadOrders; callers that write depend on IWriteOrders (or IOrderService). Implementations can provide only IReadOrders (e.g. read-only replica) or both. Tests can stub IReadOrders with just the read methods. No client is forced to depend on methods it does not use.


Dependency Inversion (DIP)

What it is and why it matters

Dependency Inversion: High-level modules should not depend on low-level modules; both should depend on abstractions. In practice: depend on interfaces (e.g. IOrderRepository), not concrete types (e.g. SqlOrderRepository). Dependency injection implements DIP: the container provides the concrete implementation; your code depends only on the interface.

Before (dependency on concretion)

public class OrderService
{
    private readonly SqlOrderRepository _repo = new SqlOrderRepository();

    public Order Get(int id) => _repo.GetById(id);
}

Problems: OrderService is tightly coupled to SqlOrderRepository. You cannot unit test with a fake repository or switch to a different storage without changing OrderService.

After (depend on abstraction, inject implementation)

public interface IOrderRepository
{
    Order GetById(int id);
    void Add(Order order);
}

public class OrderService
{
    private readonly IOrderRepository _repo;

    public OrderService(IOrderRepository repo) => _repo = repo;

    public Order Get(int id) => _repo.GetById(id);
}

// Registration (Program.cs)
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();

How this fits together: OrderService depends only on IOrderRepository. The container provides SqlOrderRepository (or a mock in tests). High-level (OrderService) and low-level (SqlOrderRepository) both depend on the abstraction IOrderRepository. You can swap implementations without changing OrderService.


Enterprise practices

  1. Apply SRP at the class and module level. One class = one reason to change; one module = one cohesive area (e.g. ordering, billing).
  2. Use OCP for variation. When you have multiple “types” of behavior (discounts, payment methods, handlers), use interfaces + implementations and DI so new behavior is added by new code.
  3. Design for LSP when using inheritance. Prefer composition over inheritance when substitution is unclear; use interfaces to define contracts that subtypes must honor.
  4. Segregate interfaces by client need. If a client only reads, give it IReadX. Avoid fat interfaces that force implementers to throw NotSupportedException for unused methods.
  5. Depend on abstractions everywhere. Controllers and services depend on IOrderRepository, IOrderService, etc. Register implementations in DI; use the composition root to wire concretions.

When to relax SOLID

  • Simple code or prototypes – Over-abstracting a 50-line script can slow you down. Apply SOLID when the code will evolve or be tested.
  • Performance-critical paths – Sometimes a single class or inline logic is clearer and faster; document the trade-off.
  • Stable, unlikely-to-change code – If a small module will never change, strict SRP/OCP may not pay off.
  • Do not use SOLID as an excuse for over-engineering – Prefer the simplest design that meets SRP, OCP, LSP, ISP, DIP where they add value. KISS and YAGNI still apply.

Summary

SOLID is five principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—in .NET applied with interfaces, DI, and focused classes. Ignoring them leads to rigid, hard-to-test code; applying them where the codebase will evolve keeps change isolated and extensions cleaner. Next, pick one class or module that has multiple reasons to change or concrete dependencies, then refactor toward one responsibility and depend on an abstraction; use the before/after examples in this article as a template.



Position & Rationale

I apply SOLID when the codebase will be touched by more than one person, when we need unit tests with mocks, or when we expect to swap implementations (e.g. another repository, another notification channel). I don’t treat it as dogma for every class—sometimes a small, stable module doesn’t need five interfaces. The rationale here is testability and change isolation: if you can’t test a use case without the real DB, or you can’t add a new discount type without editing three places, SOLID (especially DIP and OCP) gives you a path. I’ve used it in .NET for domain and application layers; infrastructure and presentation implement the interfaces and get wired in the composition root.


Trade-Offs & Failure Modes

  • What you give up: More files, more interfaces, more indirection. Onboarding takes longer until people internalise “one reason to change” and “depend on abstractions.” You also pay in upfront design—figuring out the right interface boundaries.
  • Where it goes wrong: When nobody enforces the boundaries. One “god” service that does validation, persistence, and notification “because it’s easier.” Or interfaces that leak implementation details (e.g. SaveOrder(Order, SqlConnection)). Another failure: applying SOLID to every single class in a tiny app so you end up with 20 interfaces and no second implementation.
  • How it fails when misapplied: Adding interfaces “for SOLID” with only one implementation and no tests that use the abstraction. Or the opposite—refusing to split a 2,000-line class because “SRP is overkill.” Early warning signs: “we have SOLID but our domain still references DbContext”; “we’re not sure if this is application or infrastructure”; “every new feature touches the same three files.”
  • Early warning signs: High-level code depending on concrete types; tests that need the real database; one class with five reasons to change and nobody wants to touch it.

What Most Guides Miss

Most guides show the five principles and a toy example, then stop. The bit they skip: DIP is where testability actually comes from. If your use case depends on new SqlOrderRepository(), you can’t unit test it without a database. Inject IOrderRepository and suddenly tests use a fake. Another gap: LSP isn’t just “subtypes must substitute.” In practice it’s “don’t weaken preconditions or strengthen postconditions”—so no throwing in a subtype when the base doesn’t, and no returning null when the base returns a value. People violate LSP with the best intentions (e.g. Square extending Rectangle and overriding setters). Finally: when to relax. SOLID isn’t for one-off scripts or 50-line utilities. Apply it where code will evolve and be tested; skip the extra interfaces when the module is stable and tiny. Most posts don’t say that—they imply you must SOLID everything.


Decision Framework

  • If greenfield and non-trivial domain → Start with SRP and DIP (interfaces for repositories, services); add OCP when you have multiple strategies or handlers.
  • If brownfield → Extract one area (e.g. order placement) into clear responsibilities and inject dependencies; leave the rest until you have capacity.
  • If “where does this go?” is unclear → Ask: “If I swapped the database or the notification channel, would this change?” No → domain or application; Yes → infrastructure.
  • If the class has more than one reason to change → Split; give each class one job. If you have only one implementation today, the interface still pays off for tests and future swaps.
  • If someone says “SOLID is overkill” → For a 50-line script, maybe. For code that multiple people change or that you need to test in isolation, it’s not.

Key Takeaways

  • SOLID is five principles: SRP, OCP, LSP, ISP, DIP. In .NET, apply them with interfaces, DI, and focused classes.
  • DIP is what makes unit testing possible—depend on IOrderRepository, inject a fake in tests.
  • One reason to change per class; extend by new code (OCP); subtypes substitutable for base (LSP); small interfaces (ISP).
  • Don’t SOLID everything—apply where code will evolve and be tested; relax for small, stable, or one-off code.

When I Would Use This Again — and When I Wouldn’t

I would apply SOLID again when: building or refactoring domain or application-layer code in .NET that multiple people will change, that needs unit tests with mocks, or that will evolve (new features, new implementations). Same applies to libraries and shared packages where interface stability matters.

I wouldn’t push SOLID when: the codebase is small and stable, the team does not use DI or testing, or the code is clearly one-off (scripts, migrations, throwaway tooling). I wouldn’t add interfaces “for SOLID” without a second implementation or tests that use them. For performance-critical or high-churn experimental code, I’d keep SOLID light and revisit when the design stabilizes.

Need architectural guidance for real-world .NET platforms? I offer consulting for .NET architecture, API platforms, and enterprise system design.


services
Frequently Asked Questions

Frequently Asked Questions

What is SOLID?

SOLID is five object-oriented design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. They help you write maintainable, testable, and flexible code.

What is Single Responsibility (SRP)?

A class should have one reason to change. Split responsibilities (e.g. validation, persistence, notification) into separate classes so that each has one clear job.

What is Open/Closed (OCP)?

Open for extension (add new behavior via new code), closed for modification (avoid changing existing code). Use interfaces and new implementations (e.g. new discount strategy) instead of editing existing methods (e.g. adding a new case in a switch).

What is Liskov Substitution (LSP)?

Subtypes must be substitutable for their base types. Code that expects the base type should work with any subtype without knowing the difference. Do not weaken preconditions, strengthen postconditions, or throw in cases the base does not.

What is Interface Segregation (ISP)?

Clients should not depend on interfaces they do not use. Prefer small, focused interfaces (e.g. IReadOrders, IWriteOrders) over one large interface so that callers and implementers depend only on what they need.

What is Dependency Inversion (DIP)?

Depend on abstractions (interfaces), not concretions. High-level and low-level modules both depend on interfaces; the composition root (e.g. DI container) wires concrete implementations. In .NET, use constructor injection and register interface → implementation.

Why follow SOLID?

Testability (small units, injectable dependencies), maintainability (changes are localized), flexibility (extend by new code, swap implementations). Reduces coupling and makes evolution safer.

SRP example in .NET?

Separate OrderValidator (validation), IOrderRepository (persistence), IOrderNotificationService (email). OrderService orchestrates and depends on these abstractions; each class has one reason to change.

OCP example in .NET?

Use IDiscountStrategy and multiple implementations (StandardDiscountStrategy, PremiumDiscountStrategy). Add PlatinumDiscountStrategy without changing existing code; register in DI and extend the strategy map.

LSP violation example?

Square inheriting Rectangle and overriding Width/Height so they stay equal. Code that sets Width and Height independently breaks when given a Square. Fix: do not inherit, or use an interface (IShape) that only exposes what is common (Area).

ISP example in .NET?

IReadOrders and IWriteOrders instead of one IOrderService with read and write. OrderReporter depends only on IReadOrders; implementers can provide read-only or read-write.

DIP example in .NET?

OrderService depends on IOrderRepository (injected in constructor). Program.cs registers AddScoped<IOrderRepository, SqlOrderRepository>. Tests inject a mock IOrderRepository. High-level code never references SqlOrderRepository directly.

Most important SOLID principle?

Depends on context. DIP is crucial for testability and swapping implementations. SRP is the foundation for maintainability. OCP matters when you have many variations (strategies, handlers). All five work together.

When is it OK to violate SOLID?

Simple scripts, prototypes, or code that will not change. Performance-critical paths where abstraction cost is measurable. Document the trade-off and revisit when the code evolves.

SOLID vs DRY vs KISS?

SOLID guides structure (responsibilities, dependencies, interfaces). DRY avoids duplication. KISS favors simplicity. They are complementary: apply SOLID where it adds value without over-engineering (KISS); keep logic in one place (DRY) while respecting SRP (one class, one responsibility).

services
Related Guides & Resources

services
Related services