SOLID Principles with Examples
- Single Responsibility: one reason to change per class.
- Open/Closed: extend behaviour by adding code, not modifying existing code.
- Liskov Substitution: subclasses must work anywhere the base class works.
- Interface Segregation: small client-specific interfaces instead of a fat one.
- Dependency Inversion: high- and low-level modules depend on abstractions; inject dependencies.
Follow with one concrete example from your own project, e.g. "payment providers behind IPaymentGateway, registered in DI — that's OCP and DIP."
In short: An InvoiceService that calculates totals, writes to SQL and sends email has three reasons to change.
- Split it into
InvoiceCalculator,IInvoiceRepositoryandIInvoiceSender, with a small workflow class orchestrating them. - Now an SMTP-to-SendGrid change touches only the sender, and each part has focused unit tests.
In short: Through abstractions and polymorphism: define an interface (IDiscountRule, IExportFormatter), implement each variant as its own class, and let the consumer receive IEnumerable<IDiscountRule> from DI.
- Adding a variant means a new class and registration; no existing, tested code changes.
- Other OCP tools: Strategy, Decorator, middleware, filters, events.
In short: Mathematically a square is a rectangle, but if Square overrides Width/Height setters to keep sides equal, code written for Rectangle (set width 5, height 4, expect area 20) breaks and gets 16.
- The subtype changed the base's behaviour, which violates LSP.
- Fix: don't inherit; make both implement
IShapewith anArea, or make them immutable. - Practical LSP violations: overrides throwing
NotSupportedException, or a read-only collection deriving from a writable one.
In short: DIP is the design principle: depend on abstractions.
- IoC is the broader concept that control of object creation and flow is handed to a framework or container.
- DI is a technique to achieve IoC/DIP: dependencies are supplied from outside (constructor injection preferred) rather than created with
newinside the class. - ASP.NET Core's built-in container is an IoC container that performs DI.
In short: Transient (new each time), Scoped (one per request/scope), Singleton (one for the app).
- A captive dependency happens when a longer-lived service holds a shorter-lived one — e.g. a singleton injecting a scoped
DbContext. - The
DbContextthen lives forever and is shared across threads, which causes concurrency exceptions and stale data. - Fix: make the consumer scoped, or inject
IServiceScopeFactoryand create a scope per operation (common inBackgroundService).
In short: IEnumerable<T> (iterate only) → IReadOnlyCollection<T> (+Count) → IReadOnlyList<T> (+indexer) vs ICollection<T>/IList<T> (mutation).
- A method that only reads should accept
IEnumerable<T>/IReadOnlyList<T>. - Similarly, ASP.NET Core splits
IConfigurationandIOptions<T>, and logging intoILoggerandILoggerFactory.
In short: Yes.
- SOLID is a set of guidelines, not laws.
- Over-applying them creates "interface for everything" designs, needless indirection and many tiny files.
- Apply them where change is likely or testing needs seams (external services, business rules that vary).
- For stable, simple code, KISS and YAGNI win.
- Good answer: "I refactor towards SOLID when a second variant or a test need appears."
In short: Strategy, Decorator, Template Method, Observer → OCP.
- Adapter, Facade → DIP/ISP (wrap details behind your own abstraction).
- Factory/Abstract Factory → DIP (hide creation).
- Repository → DIP/SRP.
- Chain of Responsibility/middleware → SRP + OCP.
- Composite → LSP (leaf and composite are interchangeable).
In short: The Domain and Application layers define interfaces (ports) such as IOrderRepository or IEmailSender.
- The Infrastructure layer implements them with EF Core, SMTP or Azure.
- The API project (composition root) wires them together.
- Source-code dependencies point inward, so business rules don't reference EF Core or ASP.NET.
- You can test them with in-memory fakes and swap infrastructure without touching them.
In short: They are package-level principles from Robert C.
- Martin.
- Stable Dependencies: depend in the direction of stability (packages that many others rely on should change rarely).
- Stable Abstractions: stable packages should be abstract (interfaces), so they can be extended without modification.
- Together they explain why the domain/interfaces project sits at the centre and has no dependencies.
NotificationService sends email via SMTP using new SmtpClient() inside the method. The business now wants SMS and WhatsApp too, and tests keep sending real emails. What do you change?- Introduce
INotificationChannel { Task SendAsync(Notification n); }(DIP). - Implement
EmailChannel(SMTP or SendGrid),SmsChannelandWhatsAppChannel(OCP: one class per channel). - Inject
IEnumerable<INotificationChannel>intoNotificationServiceand select by the user's preference. - Move settings to
IOptions<SmtpSettings>. - In unit tests, use a fake channel, so no real emails are sent.
- Optional: a decorator for retry/logging (OCP), and a queue so sending doesn't block the request.
if (shape is Circle) ... else if (shape is Square) ... in five places. What principle is violated and how do you fix it?In short: It violates OCP (every new shape edits five places) and usually LSP/polymorphism.
- Move the behaviour into the types:
abstract double Area()(or an interface method) implemented by each shape, and callers just callshape.Area(). - If the operation shouldn't live in the shape (e.g. rendering), use the Visitor pattern, or a dictionary of strategies keyed by type.
In short: AddHostedService registers hosted services as singletons.
- Injecting a scoped service (like
DbContext) into the constructor is a captive dependency, so the container refuses it. - Inject
IServiceScopeFactoryinstead; - inside
ExecuteAsync, for each unit of work, do `using var scope = factory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService
();`. - Each iteration then gets a fresh, correctly disposed context.
In short: Give a concrete STAR-style example.
- "Our reporting module had exports to PDF and Excel inside one 2,000-line class.
- I introduced
IReportExporterwith one class per format and registered them in DI. - The controller picks one by format key (OCP, SRP).
- Data access moved behind
IReportRepository(DIP), which let us unit-test the exporters with fake data. - Adding CSV later took half a day and touched no existing code."