OOP Fundamentals
Core OOP Pillars
Object-Oriented Programming is built on four fundamental pillars that work together to create maintainable, scalable software systems.
Abstraction
Definition: Hide complex implementation details while exposing a simple, clear interface.
Key Principles:
- Focus on what an object does rather than how it does it
- Define contracts through interfaces and abstract classes
- Reduce complexity by hiding unnecessary details
Example:
// Abstract interface - defines what, not how
public interface IPaymentProcessor
{
Task<PaymentResult> ProcessPayment(decimal amount);
Task<bool> RefundPayment(string transactionId);
}
// Concrete implementation - defines how
public class StripePaymentProcessor : IPaymentProcessor
{
private readonly string apiKey;
public StripePaymentProcessor(string apiKey)
{
this.apiKey = apiKey;
}
public async Task<PaymentResult> ProcessPayment(decimal amount)
{
// Complex Stripe API logic hidden from caller
var charge = await CreateStripeCharge(amount, apiKey);
return new PaymentResult { Success = true, TransactionId = charge.Id };
}
public async Task<bool> RefundPayment(string transactionId)
{
// Complex refund logic abstracted away
await ProcessStripeRefund(transactionId);
return true;
}
private async Task<StripeCharge> CreateStripeCharge(decimal amount, string key) { /* ... */ }
private async Task ProcessStripeRefund(string id) { /* ... */ }
}
// Consumer doesn't need to know implementation details
public class OrderService
{
private readonly IPaymentProcessor paymentProcessor;
public OrderService(IPaymentProcessor paymentProcessor)
{
this.paymentProcessor = paymentProcessor;
}
public async Task CompleteOrder(Order order)
{
// Simple interface - complexity is abstracted
var result = await paymentProcessor.ProcessPayment(order.Total);
if (result.Success)
{
order.MarkAsPaid(result.TransactionId);
}
}
}
Encapsulation
Definition: Bundle data and methods that operate on that data within a single unit, controlling access through visibility modifiers.
Key Principles:
- Keep internal state private
- Expose behavior through public methods
- Use properties for controlled access to data
- Protect object invariants
Example:
public class BankAccount
{
// Private fields - internal state is hidden
private decimal balance;
private readonly List<Transaction> transactions = new();
// Public property with controlled access
public string AccountNumber { get; }
public decimal Balance => balance; // Read-only access
public BankAccount(string accountNumber, decimal initialBalance)
{
if (initialBalance < 0)
throw new ArgumentException("Initial balance cannot be negative");
AccountNumber = accountNumber;
balance = initialBalance;
}
// Public methods control how state can be modified
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Deposit amount must be positive");
balance += amount;
transactions.Add(new Transaction(TransactionType.Deposit, amount));
}
public bool Withdraw(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Withdrawal amount must be positive");
if (amount > balance)
return false; // Insufficient funds
balance -= amount;
transactions.Add(new Transaction(TransactionType.Withdrawal, amount));
return true;
}
// Encapsulated business logic
public IReadOnlyList<Transaction> GetRecentTransactions(int count)
{
return transactions.TakeLast(count).ToList().AsReadOnly();
}
}
Inheritance
Definition: Create new classes based on existing classes, establishing “is-a” relationships and enabling code reuse.
Key Principles:
- Use inheritance for true “is-a” relationships
- Prefer composition over inheritance when possible
- Follow the Liskov Substitution Principle
- Avoid deep inheritance hierarchies (3+ levels)
Example:
// Base class with common functionality
public abstract class Employee
{
public string Name { get; set; }
public string EmployeeId { get; set; }
public decimal BaseSalary { get; set; }
// Virtual method can be overridden
public virtual decimal CalculateMonthlyPay()
{
return BaseSalary / 12;
}
// Abstract method must be implemented
public abstract string GetEmployeeType();
public void DisplayInfo()
{
Console.WriteLine($"{GetEmployeeType()}: {Name} ({EmployeeId})");
Console.WriteLine($"Monthly Pay: ${CalculateMonthlyPay():F2}");
}
}
// Derived class - SalariedEmployee "is-an" Employee
public class SalariedEmployee : Employee
{
public override string GetEmployeeType() => "Salaried Employee";
}
// Derived class with additional behavior
public class HourlyEmployee : Employee
{
public decimal HourlyRate { get; set; }
public int HoursWorked { get; set; }
// Override to change behavior
public override decimal CalculateMonthlyPay()
{
return HourlyRate * HoursWorked;
}
public override string GetEmployeeType() => "Hourly Employee";
}
// Derived class with bonus structure
public class CommissionEmployee : Employee
{
public decimal CommissionRate { get; set; }
public decimal Sales { get; set; }
public override decimal CalculateMonthlyPay()
{
return base.CalculateMonthlyPay() + (Sales * CommissionRate);
}
public override string GetEmployeeType() => "Commission Employee";
}
Modern Alternative - Composition Over Inheritance:
// Interface defines capability
public interface IPayCalculator
{
decimal CalculatePay();
}
// Different calculation strategies
public class SalaryCalculator : IPayCalculator
{
private readonly decimal annualSalary;
public SalaryCalculator(decimal annualSalary)
{
this.annualSalary = annualSalary;
}
public decimal CalculatePay() => annualSalary / 12;
}
public class HourlyCalculator : IPayCalculator
{
private readonly decimal hourlyRate;
private readonly int hoursWorked;
public HourlyCalculator(decimal hourlyRate, int hoursWorked)
{
this.hourlyRate = hourlyRate;
this.hoursWorked = hoursWorked;
}
public decimal CalculatePay() => hourlyRate * hoursWorked;
}
// Employee uses composition instead of inheritance
public class ModernEmployee
{
public string Name { get; set; }
public string EmployeeId { get; set; }
// Composition - has-a relationship
private readonly IPayCalculator payCalculator;
public ModernEmployee(string name, string employeeId, IPayCalculator payCalculator)
{
Name = name;
EmployeeId = employeeId;
this.payCalculator = payCalculator;
}
public decimal GetMonthlyPay() => payCalculator.CalculatePay();
}
Polymorphism
Definition: Objects of different types can be treated as instances of the same type, with behavior determined at runtime.
Key Principles:
- Enable flexible, extensible code
- Support method overriding (runtime polymorphism)
- Support method overloading (compile-time polymorphism)
- Work with abstractions, not concrete types
Runtime Polymorphism Example:
public interface IShape
{
double CalculateArea();
void Draw();
}
public class Circle : IShape
{
public double Radius { get; set; }
public double CalculateArea() => Math.PI * Radius * Radius;
public void Draw() => Console.WriteLine($"Drawing circle with radius {Radius}");
}
public class Rectangle : IShape
{
public double Width { get; set; }
public double Height { get; set; }
public double CalculateArea() => Width * Height;
public void Draw() => Console.WriteLine($"Drawing rectangle {Width}x{Height}");
}
public class Triangle : IShape
{
public double Base { get; set; }
public double Height { get; set; }
public double CalculateArea() => 0.5 * Base * Height;
public void Draw() => Console.WriteLine($"Drawing triangle with base {Base} and height {Height}");
}
// Polymorphic usage - treat all shapes uniformly
public class ShapeProcessor
{
public void ProcessShapes(IEnumerable<IShape> shapes)
{
foreach (var shape in shapes)
{
// Polymorphism - actual method called depends on runtime type
shape.Draw();
Console.WriteLine($"Area: {shape.CalculateArea():F2}\n");
}
}
public double CalculateTotalArea(IEnumerable<IShape> shapes)
{
return shapes.Sum(s => s.CalculateArea());
}
}
// Usage
var shapes = new List<IShape>
{
new Circle { Radius = 5 },
new Rectangle { Width = 10, Height = 20 },
new Triangle { Base = 8, Height = 6 }
};
var processor = new ShapeProcessor();
processor.ProcessShapes(shapes);
Console.WriteLine($"Total area: {processor.CalculateTotalArea(shapes):F2}");
Advanced OOP Concepts
Covariance
Definition: Use a more derived type than originally specified (out parameters, return types).
// Covariance with IEnumerable<out T>
IEnumerable<string> strings = new List<string> { "hello", "world" };
IEnumerable<object> objects = strings; // Valid - string is derived from object
// Covariance with delegates
Func<string> funcString = () => "Hello";
Func<object> funcObject = funcString; // Valid - returns more specific type
Contravariance
Definition: Use a more generic type than originally specified (in parameters).
// Contravariance with Action<in T>
Action<object> actionObject = (obj) => Console.WriteLine(obj);
Action<string> actionString = actionObject; // Valid - can accept more general parameter
// Contravariance with comparison
IComparer<object> objectComparer = Comparer<object>.Default;
IComparer<string> stringComparer = objectComparer; // Valid with contravariant interface
Invariance
Definition: Use only the originally specified type (mutable collections).
// Invariance with List<T>
List<string> strings = new List<string>();
// List<object> objects = strings; // ERROR - List is invariant
// Why? Because we could add any object to the list, breaking type safety
// objects.Add(new Employee()); // Would add Employee to List<string>!
Quick Reference
When to Use Each Pillar
Pillar | Use When | Avoid When |
---|---|---|
Abstraction | Hiding complex implementation, defining contracts | Simple, straightforward code |
Encapsulation | Protecting internal state, enforcing invariants | Data transfer objects (DTOs) |
Inheritance | True “is-a” relationships, minimal hierarchy | Code reuse alone - use composition |
Polymorphism | Working with families of related types | Single concrete implementation |
Common Anti-Patterns
Anemic Domain Model:
// BAD - Data without behavior
public class Order
{
public int Id { get; set; }
public decimal Total { get; set; }
public List<OrderItem> Items { get; set; }
}
// GOOD - Encapsulated behavior
public class Order
{
private readonly List<OrderItem> items = new();
public int Id { get; }
public decimal Total => items.Sum(i => i.Subtotal);
public IReadOnlyList<OrderItem> Items => items.AsReadOnly();
public void AddItem(OrderItem item)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
items.Add(item);
}
}
Inappropriate Intimacy:
// BAD - Classes too tightly coupled
public class Customer
{
public List<Order> Orders { get; set; }
}
public class OrderProcessor
{
public void Process(Customer customer)
{
// Direct manipulation of internal collections
customer.Orders.RemoveAll(o => o.IsCancelled);
}
}
// GOOD - Proper encapsulation
public class Customer
{
private readonly List<Order> orders = new();
public void RemoveCancelledOrders()
{
orders.RemoveAll(o => o.IsCancelled);
}
}
Found this guide helpful? Share it with your team:
Share on LinkedIn