📄

Oop Four Pillars

Intermediate 4 min read 600 words

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

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

  1. Encapsulation: Use private fields, expose through properties with validation
  2. Abstraction: Define contracts (interfaces) separate from implementation
  3. Inheritance: Use for “is-a” relationships, prefer composition for code reuse
  4. Polymorphism: Design for the interface, not the implementation
  5. All four pillars work together to create maintainable, extensible code

Further Reading