The Four Pillars of OOP: A Deep Dive with C#
Introduction
Object-Oriented Programming (OOP) is built on four fundamental pillars: Encapsulation, Abstraction, Inheritance, and Polymorphism. Understanding these concepts deeply is essential for writing maintainable, extensible C# code and succeeding in technical interviews.
Table of Contents
- Encapsulation
- Abstraction
- Inheritance
- Polymorphism
- How the Pillars Work Together
- Common Anti-Patterns
- Interview Questions
Encapsulation
Encapsulation is the bundling of data (fields) and methods that operate on that data within a single unit (class), while restricting direct access to some of the object’s components.
Information Hiding vs Encapsulation
These terms are often confused but have subtle differences:
| Concept | Definition | Focus |
|---|---|---|
| Encapsulation | Bundling data and methods together | Grouping related functionality |
| Information Hiding | Restricting access to internal state | Preventing external access |
Encapsulation is the mechanism; information hiding is the purpose.
Access Modifiers in C#
public class Employee
{
// private - only accessible within this class
private decimal _salary;
private readonly string _ssn;
// protected - accessible in this class and derived classes
protected int EmployeeNumber { get; set; }
// internal - accessible within the same assembly
internal string Department { get; set; }
// protected internal - accessible in same assembly OR derived classes
protected internal DateTime HireDate { get; set; }
// private protected - accessible only in derived classes within same assembly
private protected string InternalNotes { get; set; }
// public - accessible from anywhere
public string Name { get; set; }
public Employee(string ssn)
{
_ssn = ssn; // readonly can only be set in constructor
}
}
Properties: The C# Way
Properties provide controlled access to private fields:
public class BankAccount
{
// Backing field
private decimal _balance;
// Property with validation in setter
public decimal Balance
{
get => _balance;
private set // Private setter - can only be set internally
{
if (value < 0)
throw new ArgumentException("Balance cannot be negative");
_balance = value;
}
}
// Auto-implemented property with private setter
public string AccountNumber { get; private set; }
// Read-only property (no setter)
public bool IsOverdrawn => Balance < 0;
// Init-only property (C# 9+) - can only be set during initialization
public string AccountHolder { get; init; }
// Required property (C# 11+) - must be set during initialization
public required string Currency { get; init; }
// Methods that encapsulate behavior
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Deposit amount must be positive");
Balance += amount;
}
public void Withdraw(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Withdrawal amount must be positive");
if (amount > Balance)
throw new InvalidOperationException("Insufficient funds");
Balance -= amount;
}
}
// Usage
var account = new BankAccount
{
AccountHolder = "John Doe",
Currency = "USD"
};
account.Deposit(1000);
// account.Balance = 500; // ERROR: private setter
Encapsulation Benefits
// ❌ Without encapsulation - direct field access
public class BadEmployee
{
public decimal Salary; // Anyone can set any value!
public List<string> Skills; // Can be replaced or manipulated!
}
var bad = new BadEmployee();
bad.Salary = -50000; // No validation
bad.Skills = null; // Can break other code
bad.Skills.Clear(); // External code can modify internal state
// ✅ With encapsulation - controlled access
public class GoodEmployee
{
private decimal _salary;
private readonly List<string> _skills = new();
public decimal Salary
{
get => _salary;
set => _salary = value >= 0 ? value
: throw new ArgumentException("Salary cannot be negative");
}
// Return read-only view of collection
public IReadOnlyList<string> Skills => _skills.AsReadOnly();
public void AddSkill(string skill)
{
if (string.IsNullOrWhiteSpace(skill))
throw new ArgumentException("Skill cannot be empty");
if (!_skills.Contains(skill))
_skills.Add(skill);
}
}
var good = new GoodEmployee();
// good.Salary = -50000; // Throws exception
// good.Skills.Add("C#"); // ERROR: read-only collection
good.AddSkill("C#"); // Controlled access
Abstraction
Abstraction means showing only essential features while hiding implementation details. It’s about creating simple interfaces for complex systems.
Abstract Classes
// Abstract class defines contract + partial implementation
public abstract class Shape
{
// Abstract property - no implementation
public abstract double Area { get; }
// Abstract method - must be implemented by derived classes
public abstract double CalculatePerimeter();
// Concrete method - shared implementation
public void Display()
{
Console.WriteLine($"Shape: {GetType().Name}");
Console.WriteLine($"Area: {Area:F2}");
Console.WriteLine($"Perimeter: {CalculatePerimeter():F2}");
}
// Virtual method - can be overridden but has default implementation
public virtual string GetDescription()
{
return $"A {GetType().Name} with area {Area:F2}";
}
}
public class Circle : Shape
{
public double Radius { get; }
public Circle(double radius)
{
Radius = radius > 0 ? radius
: throw new ArgumentException("Radius must be positive");
}
// Must implement abstract members
public override double Area => Math.PI * Radius * Radius;
public override double CalculatePerimeter() => 2 * Math.PI * Radius;
// Optionally override virtual members
public override string GetDescription()
{
return $"A circle with radius {Radius:F2} and area {Area:F2}";
}
}
public class Rectangle : Shape
{
public double Width { get; }
public double Height { get; }
public Rectangle(double width, double height)
{
Width = width;
Height = height;
}
public override double Area => Width * Height;
public override double CalculatePerimeter() => 2 * (Width + Height);
}
Interfaces
// Interface defines pure contract (what, not how)
public interface IPaymentProcessor
{
// Methods
Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request);
Task<RefundResult> RefundAsync(string transactionId, decimal amount);
// Properties
string ProviderName { get; }
bool SupportsRecurring { get; }
// Default interface method (C# 8+)
bool IsAvailable() => true;
// Static abstract member (C# 11+)
static abstract string ApiVersion { get; }
}
// Multiple interface implementation
public class StripePaymentProcessor : IPaymentProcessor, IDisposable
{
public string ProviderName => "Stripe";
public bool SupportsRecurring => true;
public static string ApiVersion => "2023-10-16";
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
{
// Stripe-specific implementation
return new PaymentResult { Success = true };
}
public async Task<RefundResult> RefundAsync(string transactionId, decimal amount)
{
// Stripe-specific refund logic
return new RefundResult { Success = true };
}
public void Dispose()
{
// Cleanup resources
}
}
public class PayPalPaymentProcessor : IPaymentProcessor
{
public string ProviderName => "PayPal";
public bool SupportsRecurring => true;
public static string ApiVersion => "v2";
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
{
// PayPal-specific implementation
return new PaymentResult { Success = true };
}
public async Task<RefundResult> RefundAsync(string transactionId, decimal amount)
{
// PayPal-specific refund logic
return new RefundResult { Success = true };
}
// Override default interface method
public bool IsAvailable() => CheckPayPalStatus();
private bool CheckPayPalStatus() => true;
}
When to Use Abstract Class vs Interface
| Use Abstract Class When | Use Interface When |
|---|---|
| Need shared code implementation | Define a contract only |
| Related classes share common state | Unrelated classes share behavior |
| Need protected members | Need multiple inheritance |
| Non-public access modifiers needed | Need to define a capability |
| Want to add members later without breaking | Need maximum flexibility |
// Example: Both combined
public abstract class Animal
{
// Shared state
protected string Name { get; set; }
protected int Age { get; set; }
// Shared implementation
public void Sleep() => Console.WriteLine($"{Name} is sleeping");
}
public interface ICanFly
{
void Fly();
double MaxAltitude { get; }
}
public interface ICanSwim
{
void Swim();
double MaxDepth { get; }
}
// Duck inherits from Animal AND implements multiple interfaces
public class Duck : Animal, ICanFly, ICanSwim
{
public double MaxAltitude => 6000; // meters
public double MaxDepth => 3; // meters
public void Fly() => Console.WriteLine($"{Name} is flying");
public void Swim() => Console.WriteLine($"{Name} is swimming");
}
Inheritance
Inheritance creates a hierarchy where derived classes inherit fields and methods from base classes.
C# Inheritance Basics
// Base class
public class Vehicle
{
public string Make { get; set; }
public string Model { get; set; }
public int Year { get; set; }
// Virtual - can be overridden
public virtual void Start()
{
Console.WriteLine("Vehicle starting...");
}
// Non-virtual - cannot be overridden (but can be hidden with 'new')
public void Stop()
{
Console.WriteLine("Vehicle stopping...");
}
}
// Derived class
public class Car : Vehicle
{
public int NumberOfDoors { get; set; }
// Override base implementation
public override void Start()
{
Console.WriteLine($"Car {Make} {Model} starting with ignition...");
}
// New method specific to Car
public void Honk()
{
Console.WriteLine("Beep beep!");
}
}
// Further derivation
public class ElectricCar : Car
{
public int BatteryCapacityKwh { get; set; }
public override void Start()
{
Console.WriteLine($"Electric car {Make} {Model} starting silently...");
}
public void Charge()
{
Console.WriteLine("Charging battery...");
}
}
The ‘sealed’ Keyword
// Sealed class - cannot be inherited
public sealed class FinalReport
{
public string Content { get; set; }
}
// public class ExtendedReport : FinalReport { } // ERROR: Cannot derive from sealed
// Sealed method - prevents further overriding
public class BaseLogger
{
public virtual void Log(string message) => Console.WriteLine(message);
}
public class FileLogger : BaseLogger
{
public sealed override void Log(string message)
{
File.AppendAllText("log.txt", message);
}
}
public class TimestampedFileLogger : FileLogger
{
// Cannot override Log - it's sealed
// public override void Log(string message) { } // ERROR
// Can add new methods
public void LogWithTimestamp(string message)
{
Log($"[{DateTime.Now}] {message}");
}
}
Constructor Chaining
public class Person
{
public string Name { get; }
public int Age { get; }
public Person(string name) : this(name, 0) { }
public Person(string name, int age)
{
Name = name;
Age = age;
}
}
public class Employee : Person
{
public string Department { get; }
public decimal Salary { get; }
// Call base constructor
public Employee(string name, string department)
: base(name)
{
Department = department;
}
// Call another constructor in same class, then base
public Employee(string name, int age, string department, decimal salary)
: base(name, age)
{
Department = department;
Salary = salary;
}
}
Single Inheritance Limitation
// C# only supports single class inheritance
public class A { }
public class B : A { }
// public class C : A, B { } // ERROR: Cannot inherit from multiple classes
// Solution: Use interfaces for multiple "inheritance"
public interface ILoggable
{
void Log(string message);
}
public interface ISerializable
{
string Serialize();
void Deserialize(string data);
}
// Can implement multiple interfaces
public class DataProcessor : A, ILoggable, ISerializable
{
public void Log(string message) => Console.WriteLine(message);
public string Serialize() => JsonSerializer.Serialize(this);
public void Deserialize(string data) { /* ... */ }
}
Polymorphism
Polymorphism allows objects to be treated as instances of their parent class while executing behavior specific to their actual class.
Compile-Time Polymorphism (Overloading)
public class Calculator
{
// Method overloading - same name, different parameters
public int Add(int a, int b) => a + b;
public double Add(double a, double b) => a + b;
public decimal Add(decimal a, decimal b) => a + b;
// Different number of parameters
public int Add(int a, int b, int c) => a + b + c;
// Different parameter types (not just different names)
public string Add(string a, string b) => a + b;
// ❌ These would NOT be valid overloads:
// public void Add(int x, int y) { } // Only return type differs
// public int Add(int first, int second) { } // Only parameter names differ
}
// Operator overloading
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
// Operator overloading
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException("Currency mismatch");
return new Money(a.Amount + b.Amount, a.Currency);
}
public static Money operator -(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException("Currency mismatch");
return new Money(a.Amount - b.Amount, a.Currency);
}
public static bool operator ==(Money a, Money b)
=> a.Amount == b.Amount && a.Currency == b.Currency;
public static bool operator !=(Money a, Money b) => !(a == b);
}
Runtime Polymorphism (Overriding)
public abstract class Notification
{
public abstract Task SendAsync(string message, string recipient);
}
public class EmailNotification : Notification
{
public override async Task SendAsync(string message, string recipient)
{
Console.WriteLine($"Sending email to {recipient}: {message}");
await Task.Delay(100); // Simulate network call
}
}
public class SmsNotification : Notification
{
public override async Task SendAsync(string message, string recipient)
{
Console.WriteLine($"Sending SMS to {recipient}: {message}");
await Task.Delay(50);
}
}
public class PushNotification : Notification
{
public override async Task SendAsync(string message, string recipient)
{
Console.WriteLine($"Sending push to {recipient}: {message}");
await Task.Delay(30);
}
}
// Usage - polymorphism in action
public class NotificationService
{
private readonly List<Notification> _channels = new();
public void AddChannel(Notification channel)
{
_channels.Add(channel);
}
public async Task NotifyAllAsync(string message, string recipient)
{
// All notifications treated uniformly through base class
foreach (var channel in _channels)
{
await channel.SendAsync(message, recipient); // Actual type's method called
}
}
}
// Example usage
var service = new NotificationService();
service.AddChannel(new EmailNotification());
service.AddChannel(new SmsNotification());
service.AddChannel(new PushNotification());
await service.NotifyAllAsync("Hello!", "user@example.com");
virtual, override, and new Keywords
public class Base
{
public virtual void VirtualMethod()
{
Console.WriteLine("Base.VirtualMethod");
}
public void NonVirtualMethod()
{
Console.WriteLine("Base.NonVirtualMethod");
}
}
public class Derived : Base
{
// Override - replaces the base method (polymorphic)
public override void VirtualMethod()
{
Console.WriteLine("Derived.VirtualMethod");
}
// New - hides the base method (non-polymorphic)
public new void NonVirtualMethod()
{
Console.WriteLine("Derived.NonVirtualMethod");
}
}
// Demonstrating the difference
Base obj1 = new Derived();
Derived obj2 = new Derived();
obj1.VirtualMethod(); // "Derived.VirtualMethod" (polymorphism works)
obj2.VirtualMethod(); // "Derived.VirtualMethod"
obj1.NonVirtualMethod(); // "Base.NonVirtualMethod" (hidden, not polymorphic!)
obj2.NonVirtualMethod(); // "Derived.NonVirtualMethod"
Interface Polymorphism
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(int id);
}
public class SqlRepository<T> : IRepository<T> where T : class
{
private readonly DbContext _context;
public SqlRepository(DbContext context) => _context = context;
public async Task<T?> GetByIdAsync(int id)
=> await _context.Set<T>().FindAsync(id);
public async Task<IEnumerable<T>> GetAllAsync()
=> await _context.Set<T>().ToListAsync();
// ... other implementations
}
public class MongoRepository<T> : IRepository<T> where T : class
{
private readonly IMongoCollection<T> _collection;
public MongoRepository(IMongoDatabase database, string collectionName)
{
_collection = database.GetCollection<T>(collectionName);
}
public async Task<T?> GetByIdAsync(int id)
{
// MongoDB-specific implementation
var filter = Builders<T>.Filter.Eq("_id", id);
return await _collection.Find(filter).FirstOrDefaultAsync();
}
// ... other implementations
}
// Service uses interface - doesn't care about implementation
public class ProductService
{
private readonly IRepository<Product> _repository;
public ProductService(IRepository<Product> repository)
{
_repository = repository; // Could be SQL, Mongo, or any implementation
}
public async Task<Product?> GetProductAsync(int id)
{
return await _repository.GetByIdAsync(id);
}
}
How the Pillars Work Together
// Real-world example combining all four pillars
public abstract class PaymentMethod // ABSTRACTION
{
// ENCAPSULATION - private field, public property
private readonly string _merchantId;
public string MerchantId => _merchantId;
// Protected member for derived classes
protected decimal TransactionFeeRate { get; set; } = 0.029m;
protected PaymentMethod(string merchantId)
{
_merchantId = merchantId;
}
// ABSTRACTION - abstract method
public abstract Task<PaymentResult> ProcessAsync(decimal amount);
// Shared implementation
protected decimal CalculateFee(decimal amount) => amount * TransactionFeeRate;
}
// INHERITANCE
public class CreditCardPayment : PaymentMethod
{
private readonly string _cardNumber;
private readonly string _cvv;
public CreditCardPayment(string merchantId, string cardNumber, string cvv)
: base(merchantId)
{
_cardNumber = cardNumber;
_cvv = cvv;
TransactionFeeRate = 0.029m; // 2.9%
}
// POLYMORPHISM - override abstract method
public override async Task<PaymentResult> ProcessAsync(decimal amount)
{
var fee = CalculateFee(amount);
// Process credit card payment
return new PaymentResult
{
Success = true,
TransactionId = Guid.NewGuid().ToString(),
Fee = fee
};
}
}
public class BankTransferPayment : PaymentMethod
{
private readonly string _accountNumber;
private readonly string _routingNumber;
public BankTransferPayment(string merchantId, string account, string routing)
: base(merchantId)
{
_accountNumber = account;
_routingNumber = routing;
TransactionFeeRate = 0.005m; // 0.5% - lower fee
}
// POLYMORPHISM
public override async Task<PaymentResult> ProcessAsync(decimal amount)
{
var fee = CalculateFee(amount);
// Process bank transfer
return new PaymentResult
{
Success = true,
TransactionId = Guid.NewGuid().ToString(),
Fee = fee
};
}
}
// POLYMORPHISM in action
public class PaymentProcessor
{
public async Task<PaymentResult> ProcessPaymentAsync(
PaymentMethod paymentMethod, // Accept any payment type
decimal amount)
{
// Same code handles all payment types
return await paymentMethod.ProcessAsync(amount);
}
}
// Usage
var processor = new PaymentProcessor();
PaymentMethod creditCard = new CreditCardPayment("M123", "4111...", "123");
PaymentMethod bankTransfer = new BankTransferPayment("M123", "123456", "021000021");
// Same method, different behaviors
var result1 = await processor.ProcessPaymentAsync(creditCard, 100m);
var result2 = await processor.ProcessPaymentAsync(bankTransfer, 100m);
Common Anti-Patterns
1. Breaking Encapsulation
// ❌ Anti-pattern: Exposing internal state
public class Order
{
public List<OrderItem> Items { get; set; } = new(); // Mutable collection exposed
}
var order = new Order();
order.Items.Clear(); // External code can manipulate internal state
// ✅ Correct: Protect internal state
public class Order
{
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public void AddItem(OrderItem item)
{
// Validation logic here
_items.Add(item);
}
}
2. Inheritance for Code Reuse (Not “is-a”)
// ❌ Anti-pattern: Inheriting just to reuse code
public class Stack<T> : List<T> // Stack is NOT a List
{
public void Push(T item) => Add(item);
public T Pop()
{
var item = this[Count - 1];
RemoveAt(Count - 1);
return item;
}
}
var stack = new Stack<int>();
stack.Push(1);
stack.Insert(0, 999); // Uh oh! List operations break stack invariant
// ✅ Correct: Use composition
public class Stack<T>
{
private readonly List<T> _items = new();
public void Push(T item) => _items.Add(item);
public T Pop()
{
if (_items.Count == 0)
throw new InvalidOperationException("Stack empty");
var item = _items[^1];
_items.RemoveAt(_items.Count - 1);
return item;
}
public T Peek() => _items[^1];
public int Count => _items.Count;
}
3. God Classes (Violating Single Responsibility)
// ❌ Anti-pattern: One class does everything
public class UserManager
{
public void CreateUser(User user) { }
public void SendEmail(string to, string subject, string body) { }
public void LogActivity(string message) { }
public bool ValidateCreditCard(string cardNumber) { }
public void GenerateReport() { }
}
// ✅ Correct: Separate concerns
public class UserService { public void CreateUser(User user) { } }
public class EmailService { public void SendEmail(...) { } }
public class Logger { public void LogActivity(...) { } }
public class PaymentValidator { public bool ValidateCreditCard(...) { } }
public class ReportGenerator { public void GenerateReport() { } }
Interview Questions
1. What is the difference between encapsulation and abstraction?
Answer:
- Encapsulation: Bundles data and methods together, hiding internal state. Focuses on “how” to restrict access.
- Abstraction: Hides complexity, shows only essential features. Focuses on “what” to expose.
Example: A Car class has private fields for engine components (encapsulation) and exposes simple Start()/Stop() methods (abstraction).
2. Can you have a constructor in an abstract class?
Answer: Yes! Abstract classes can have constructors that are called when derived classes are instantiated.
public abstract class Animal
{
protected string Name { get; }
protected Animal(string name)
{
Name = name;
}
}
public class Dog : Animal
{
public Dog(string name) : base(name) { } // Must call base constructor
}
3. What is the difference between virtual, override, and new?
Answer:
- virtual: Declares a method can be overridden in derived classes
- override: Replaces the base class implementation (polymorphic)
- new: Hides the base class method (non-polymorphic)
Key difference: With override, the derived method is called even when using a base class reference. With new, the base class method is called when using a base class reference.
4. Why does C# not support multiple inheritance?
Answer: To avoid the “diamond problem” where a class inherits from two classes that both inherit from a common base class, creating ambiguity about which implementation to use.
C# solves this with:
- Single class inheritance
- Multiple interface implementation
- Default interface methods (C# 8+) for shared code
5. What is the Liskov Substitution Principle and how does it relate to polymorphism?
Answer: LSP states that objects of a derived class must be substitutable for objects of the base class without affecting program correctness.
Violation example:
public class Rectangle { virtual void SetWidth(int w) {...} }
public class Square : Rectangle
{
override void SetWidth(int w) { Width = Height = w; } // Violates LSP
}
A Square that changes both dimensions when you set width breaks code expecting Rectangle behavior.
Key Takeaways
- Encapsulation: Use private fields, expose through properties with validation
- Abstraction: Define contracts (interfaces) separate from implementation
- Inheritance: Use for “is-a” relationships, prefer composition for code reuse
- Polymorphism: Design for the interface, not the implementation
- All four pillars work together to create maintainable, extensible code