12 questions on object-oriented design and SOLID: interfaces and abstract classes, the five principles, IoC, composition, and the Liskov and fragile base class traps.
01OOP & SOLIDFoundation
Abstract Classes vs Interfaces
Answer
An abstract class represents an incomplete base type in an is-a hierarchy. It can contain state (fields), constructors, and a mix of concrete and abstract members. A class can inherit from only one base class.
An interface defines a behavioral contract in a can-do relationship. A class can implement multiple interfaces. Interfaces decouple what an object does from how it is implemented.
Modern C# Enhancements: C# 8 introduced Default Interface Methods, allowing interfaces to provide default method bodies without breaking existing implementations. C# 11 introduced static abstract members in interfaces, enabling generic math and static factory contracts.
Flow diagram: Abstract class shared state vs interface capability contracts
Choose an Abstract Class when: multiple closely related classes share internal state and helper methods; you need non-public members (protected); you want to implement the Template Method pattern where the base class controls the algorithm execution skeleton.
Choose an Interface when: unrelated classes across different modules share the same capability (IDisposable, IComparable); you need dependency injection seams for unit testing and mocking; you need multiple behavioral contracts on a single type.
Pitfall: Stacking deep inheritance trees using abstract base classes creates fragile hierarchies. Prefer small, cohesive interfaces combined with composition.
02OOP & SOLIDFoundation
Sealed and interfaces
Answer
sealed on a class blocks further inheritance. Callers know the concrete type will not grow a surprise subclass.
sealed on an override stops a derived type from overriding that member again. Use it when the override must stay final.
Interfaces define a contract. Unrelated types can implement the same interface without sharing a base class.
Seal types you own when further extension would break invariants, or when the type is a leaf DTO or helper with no planned hierarchy.
Prefer an interface when many implementations should plug in under DIP. Prefer a sealed class when you want one fixed concrete type.
Example: seal OrderDto and Money helpers; expose IOrderPricing so new pricing rules register without subclassing a sealed helper.
Pitfall: sealing everything blocks legitimate extension. Seal when the API surface must stay closed, not as a default for every class.
In .NET, records are often sealed by design for value-like DTOs. Interfaces remain the open point for behavior.
The Single Responsibility Principle (defined by Robert C. Martin) states: A class should have one, and only one, reason to change.
Actor-Based Cohesion: A "reason to change" is anchored in the people or business actors who request that change. If a single class contains methods requested by the Accounting department (calculating salary), the Operations department (scheduling hours), and the IT department (saving employee records to SQL), the class violates SRP because changes requested by Accounting risk introducing regressions into Operations.
High Cohesion and Low Coupling: SRP promotes high cohesion (keeping related logic together) and low coupling (isolating unrelated concerns into independent classes).
The God Class Antipattern: Classes that handle business validation, database queries, email formatting, and logging inside a single file violate SRP, resulting in code that is difficult to test, maintain, and merge concurrently.
Flow diagram: Splitting multi-responsibility God Class into cohesive actor-aligned services
The Open/Closed Principle states: Software entities (classes, modules, functions) should be open for extension, but closed for modification.
Extending Behavior without Editing Source: You should be able to add new functionality to a module without opening, altering, and recompiling the existing, tested source code.
Achieving OCP via Polymorphism: Instead of writing monolithic if/else or switch statements that inspect type flags (which must be modified every time a new business type is introduced), define an interface or abstract base class. New behaviors are introduced by creating new classes implementing the interface.
Real-world Pattern: Strategy Pattern and Dependency Injection are the primary mechanisms used in .NET to enforce the Open/Closed Principle.
Flow diagram: Extending discounts via OCP polymorphism vs modifying switch statement
C# (OCP Discount Strategy)
publicinterfaceIDiscountStrategy
{
boolIsApplicable(Customer customer);
decimalApplyDiscount(decimal total);
}
publicclassVipDiscountStrategy : IDiscountStrategy
{
publicboolIsApplicable(Customer customer) => customer.IsVip;
publicdecimalApplyDiscount(decimal total) => total * 0.80m;
}
// New discount types are added as new classes without modifying OrderPricingCalculatorpublicclassOrderPricingCalculator
{
privatereadonlyIEnumerable<IDiscountStrategy> _strategies;
publicOrderPricingCalculator(IEnumerable<IDiscountStrategy> strategies) => _strategies = strategies;
publicdecimalCalculate(Order order, Customer customer)
{
var strategy = _strategies.FirstOrDefault(s => s.IsApplicable(customer));
return strategy is not null ? strategy.ApplyDiscount(order.Subtotal) : order.Subtotal;
}
}
05OOP & SOLIDFoundation
Liskov Substitution Principle (LSP)
Answer
The Liskov Substitution Principle states that objects of a superclass must be replaceable with objects of a subclass without breaking application correctness, contracts, or expected behavior.
Behavioral Subtyping: Inheritance is not just about sharing method signatures; it enforces a behavioral contract. If code expects type T, providing a subtype S must not violate any assumptions callers make about T.
Preconditions and Postconditions: A subtype may not strengthen preconditions (it cannot demand more than the base type demands) and may not weaken postconditions (it must deliver at least as much as the base type guarantees).
The NotSupportedException Smell: If a derived class overrides a base method with throw new NotSupportedException() or NotImplementedException(), it violates LSP. The classic example is a Square inheriting from Rectangle and breaking width/height mutability, or a Penguin inheriting from Bird where Bird defines Fly().
Flow diagram: LSP violation vs sound behavioral hierarchy
The Dependency Inversion Principle states: High-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.
DIP vs DI vs IoC: Inversion of Control (IoC) is the overarching architectural pattern where framework code calls application code. Dependency Injection (DI) is a specific technique for supplying dependencies via constructors. Dependency Inversion (DIP) is the software design principle stating where boundaries and abstractions should sit.
Architectural Impact: Without DIP, business logic directly instantiates and depends on database drivers (SqlConnection), disk IO, or external APIs (SendGrid). With DIP, business logic declares interfaces (IOrderRepository, INotificationService) and infrastructure implements them.
Flow diagram: Direct dependency vs inverted dependency via abstractions
C# (DIP Architectural Seam)
// Domain / High-level policy owns the abstractionpublicinterfaceIPaymentGateway
{
Task<PaymentResult> ChargeAsync(decimal amount, string customerToken, CancellationToken ct);
}
// High-level service depends on the abstraction, not Stripe or PayPalpublicclassOrderCheckoutService
{
privatereadonlyIPaymentGateway _paymentGateway;
publicOrderCheckoutService(IPaymentGateway paymentGateway) => _paymentGateway = paymentGateway;
publicasyncTaskCheckoutAsync(Order order, string token, CancellationToken ct)
{
var result = await _paymentGateway.ChargeAsync(order.Total, token, ct);
if (!result.IsSuccess) thrownewPaymentFailedException(result.ErrorMessage);
}
}
08OOP & SOLIDFoundation
IoC vs dependency inversion
Answer
Dependency Inversion (DIP) is a design rule: high-level code depends on abstractions, not on concrete infrastructure.
Inversion of Control (IoC) is a runtime style: the host calls your code and supplies dependencies, instead of your code calling new everywhere.
A DI container is one way to do IoC. Constructor injection is the usual .NET style.
You can follow DIP with manual factories and no container. The container does not replace the principle.
DIP is the design rule. IoC and DI are how the wiring runs.
Example: OrderService takes IOrderRepository (DIP). The host resolves EfOrderRepository and injects it (IoC/DI).
Pitfall: saying "we use DI so we follow SOLID" confuses the tool with the principle. A container with concrete-only registrations still violates DIP.
Interview tip: define DIP without naming Autofac or MS.DI, then show how a container supports that design.
A subtype that throws NotSupportedException for a base method breaks callers that only know the base type.
Stronger preconditions or weaker postconditions also break the base contract. Callers wrote code against the weaker rules.
Square as a Rectangle: code that sets Width and Height separately expects independent sides. A Square that forces equal sides violates that.
Model roles the subtype can honor (Move vs Fly). Split interfaces instead of one hierarchy that over-promises.
Example: Stream.Seek may be unsupported on some streams. Documented capability checks exist; silent throws still surprise generic Stream users.
Pitfall: inheriting for code reuse while narrowing the API is the common LSP trap in code reviews.
Prefer composing a Square with a side length, or a Shape hierarchy with Area(), over forcing Square into Rectangle setters.
In interviews, walk through a failing unit test that uses the base type and shows the subtype surprise.
Flow diagram: Contract the subtype must keep
C#
publicclassRectangle
{
publicvirtualintWidth { get; set; }
publicvirtualintHeight { get; set; }
publicintArea => Width * Height;
}
// Fragile: Square overrides setters to keep sides equal.// Code that does r.Width = 4; r.Height = 5; expects Area 20.publicclassSquare : Rectangle
{
publicoverrideintWidth
{
get => base.Width;
set { base.Width = value; base.Height = value; }
}
publicoverrideintHeight
{
get => base.Height;
set { base.Width = value; base.Height = value; }
}
}
12OOP & SOLIDAdvanced
Advanced: fragile base class
Answer
Changing a base class can break subclasses that override methods, call base, or depend on call order.
Protected override hooks are a hidden protocol. Reordering base steps runs overrides at the wrong time.
Compose swappable behavior. Keep inheritance shallow when the base must stay free to change.
Seal methods or classes when you do not want further override.
Example: Base.Save() calls Validate then Persist. A subclass override of Validate assumes Persist never ran first. A base refactor breaks it.
Pitfall: "just add a protected virtual hook" for every extension need creates a fragile template method surface.
Framework authors seal or document override contracts carefully. Application code should prefer interfaces and composition.
If subclasses exist only to customize steps, a strategy or callback may be safer than an open inheritance API.
Flow diagram: Base change breaks subclasses
C#
publicclassReporter
{
publicvirtualvoidRun()
{
Gather();
Write();
}
protectedvirtualvoidGather() { }
protectedvirtualvoidWrite() { }
}
// Subclass overrides Gather and also calls Write.// If Run() later changes order or skips Write, the subclass breaks silently.