Object-Oriented Programming


Object-Oriented Programming isn’t just about classes and objects—it’s a paradigm that shapes how we architect entire systems.

Beyond the Basics: OOP as Architecture

Most developers learn OOP through textbook examples: Dog extends Animal, Car has Engine. But at the architect level, OOP manifests as:

  • System boundaries: What should be a microservice vs a class?
  • Coupling decisions: Where do we draw abstraction boundaries?
  • Team organization: How do we align teams with object models?
  • Performance trade-offs: When does OOP overhead matter?

The Architect’s Reality:

  • Pure OOP can be over-engineered
  • Procedural code sometimes wins
  • Functional patterns often complement OOP
  • Hybrid approaches usually work best

The Four Pillars: Architectural View

1. Encapsulation

“Hide internal state and require all interaction through methods.”

The Strategic Value

Encapsulation at scale means information hiding and contract stability:

  • Services hide implementation details behind APIs
  • Modules expose minimal surface area
  • Internal changes don’t ripple through systems

Well-Encapsulated Design

✅ Good Encapsulation:

// Encapsulates complex pricing logic
public class Order
{
    private readonly List<OrderItem> _items = new();
    private readonly DiscountEngine _discountEngine;
    private decimal _subtotal;
    private decimal _tax;
    private decimal _discount;
    
    // Internal state hidden
    public decimal Total { get; private set; }
    public OrderStatus Status { get; private set; }
    
    public Order(DiscountEngine discountEngine)
    {
        _discountEngine = discountEngine;
        Status = OrderStatus.Draft;
    }
    
    // Controlled mutations through methods
    public void AddItem(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify submitted order");
        
        _items.Add(new OrderItem(product, quantity));
        RecalculateTotal(); // Internal state management
    }
    
    public void ApplyDiscount(string couponCode)
    {
        _discount = _discountEngine.Calculate(couponCode, _subtotal);
        RecalculateTotal();
    }
    
    public void Submit()
    {
        if (_items.Count == 0)
            throw new InvalidOperationException("Cannot submit empty order");
        
        Status = OrderStatus.Submitted;
        // Internal state transition logic
    }
    
    private void RecalculateTotal()
    {
        _subtotal = _items.Sum(i => i.Price * i.Quantity);
        _tax = _subtotal * 0.08m;
        Total = _subtotal + _tax - _discount;
    }
}

Benefits:

  • Order’s invariants always maintained (can’t submit empty order)
  • Total always consistent with items
  • Status transitions controlled
  • Internal calculation logic can change without affecting clients

Broken Encapsulation

❌ Poor Encapsulation (Anemic Domain Model):

// Data bag - no behavior, no encapsulation
public class Order
{
    public List<OrderItem> Items { get; set; }
    public decimal Subtotal { get; set; }
    public decimal Tax { get; set; }
    public decimal Discount { get; set; }
    public decimal Total { get; set; }
    public OrderStatus Status { get; set; }
}

// All logic lives in services - procedural programming with extra steps
public class OrderService
{
    public void AddItem(Order order, Product product, int quantity)
    {
        order.Items.Add(new OrderItem(product, quantity));
        // Manual calculation
        order.Subtotal = order.Items.Sum(i => i.Price * i.Quantity);
        order.Tax = order.Subtotal * 0.08m;
        order.Total = order.Subtotal + order.Tax - order.Discount;
    }
    
    public void Submit(Order order)
    {
        // No validation of state - can submit invalid orders
        order.Status = OrderStatus.Submitted;
    }
}

Problems:

  • No invariant protection (can create invalid orders)
  • Logic scattered across multiple services
  • Direct property access allows inconsistent state
  • Hard to reason about object lifecycle

When to Break Encapsulation

Sometimes getters/setters are fine:

// Simple DTO for data transfer - no business logic
public class OrderDto
{
    public int Id { get; set; }
    public string CustomerName { get; set; }
    public decimal Total { get; set; }
    public DateTime OrderDate { get; set; }
}

// Configuration object - pure data
public class AppConfiguration
{
    public string DatabaseConnection { get; set; }
    public int MaxRetries { get; set; }
    public bool EnableCaching { get; set; }
}

When to skip encapsulation:

  • DTOs and data transfer objects
  • Configuration objects
  • Database entities (ORM requirements)
  • Simple value objects with no invariants

Encapsulation at Service Level

❌ Poor Service Encapsulation (Leaky Abstractions)

OrderService exposes:
- GET /orders/{id}/items (internal structure leaked)
- GET /orders/{id}/subtotal
- GET /orders/{id}/tax  
- GET /orders/{id}/discount
- POST /orders/{id}/recalculate (internal operation exposed)

Clients know too much about internal calculations

✅ Good Service Encapsulation

OrderService exposes:
- GET /orders/{id} (complete order representation)
- POST /orders (create order)
- PUT /orders/{id}/items (modify items - recalculation hidden)
- POST /orders/{id}/submit

Internal calculations are implementation details

2. Abstraction

“Expose only essential characteristics, hide implementation complexity.”

Abstraction Levels

Good architecture has appropriate abstraction layers:

Business Logic Layer
    ↓ (abstracts)
Domain Model Layer
    ↓ (abstracts)
Data Access Layer
    ↓ (abstracts)
Database

Strategic Abstraction

✅ Value-Adding Abstraction:

// Abstract payment processing complexity
public interface IPaymentProcessor
{
    Task<PaymentResult> ProcessPayment(PaymentRequest request);
    Task<RefundResult> ProcessRefund(string transactionId, decimal amount);
}

// Clients work with high-level concept
public class CheckoutService
{
    private readonly IPaymentProcessor _paymentProcessor;
    
    public async Task<CheckoutResult> Checkout(Cart cart, PaymentInfo payment)
    {
        // Abstraction hides complexity of multiple payment providers
        var paymentRequest = new PaymentRequest
        {
            Amount = cart.Total,
            Currency = "USD",
            PaymentMethod = payment.Method,
            Token = payment.Token
        };
        
        var result = await _paymentProcessor.ProcessPayment(paymentRequest);
        
        if (result.Success)
        {
            return CheckoutResult.Success(result.TransactionId);
        }
        
        return CheckoutResult.Failed(result.ErrorMessage);
    }
}

// Multiple implementations hidden behind abstraction
public class StripePaymentProcessor : IPaymentProcessor
{
    public async Task<PaymentResult> ProcessPayment(PaymentRequest request)
    {
        // Complex Stripe API calls hidden
        var options = new ChargeCreateOptions
        {
            Amount = (long)(request.Amount * 100),
            Currency = request.Currency,
            Source = request.Token,
            // 20+ more parameters...
        };
        
        var charge = await _stripeClient.Charges.CreateAsync(options);
        return MapToPaymentResult(charge);
    }
}

public class PayPalPaymentProcessor : IPaymentProcessor
{
    public async Task<PaymentResult> ProcessPayment(PaymentRequest request)
    {
        // Completely different PayPal API hidden
        var payment = new Payment
        {
            intent = "sale",
            payer = new Payer { payment_method = "paypal" },
            // Different structure than Stripe
        };
        
        var createdPayment = await _paypalClient.CreatePaymentAsync(payment);
        return MapToPaymentResult(createdPayment);
    }
}

Why This Works:

  • CheckoutService doesn’t know about Stripe vs PayPal differences
  • Can add new payment providers without changing checkout
  • Each provider handles its own complexity
  • Business logic stays clean

Over-Abstraction

❌ Abstraction Layers Without Purpose:

// Layer 1: Repository interface
public interface IUserRepository
{
    Task<User> GetById(int id);
}

// Layer 2: Repository implementation
public class UserRepository : IUserRepository
{
    public async Task<User> GetById(int id)
    {
        return await _context.Users.FindAsync(id);
    }
}

// Layer 3: Service interface
public interface IUserService
{
    Task<User> GetUser(int id);
}

// Layer 4: Service implementation
public class UserService : IUserService
{
    private readonly IUserRepository _repository;
    
    public async Task<User> GetUser(int id)
    {
        // Just passes through - adds no value
        return await _repository.GetById(id);
    }
}

// Layer 5: Facade interface
public interface IUserFacade
{
    Task<User> FetchUser(int id);
}

// Layer 6: Facade implementation
public class UserFacade : IUserFacade
{
    private readonly IUserService _service;
    
    public async Task<User> FetchUser(int id)
    {
        // Still just passing through
        return await _service.GetUser(id);
    }
}

// Controller finally uses it
public class UserController
{
    private readonly IUserFacade _facade;
    
    public async Task<IActionResult> Get(int id)
    {
        // 6 layers to read from database!
        var user = await _facade.FetchUser(id);
        return Ok(user);
    }
}

Problems:

  • Indirection overhead: 6 classes to do simple database read
  • Debugging nightmare: Step through 6 layers
  • No added value: Each layer just passes data through
  • Maintenance burden: Change signature = update 6 places

✅ Right Amount of Abstraction:

// Single repository layer is sufficient
public interface IUserRepository
{
    Task<User> GetById(int id);
    Task<User> GetByEmail(string email);
    Task<IEnumerable<User>> GetAll();
    Task Add(User user);
    Task Update(User user);
}

public class UserController
{
    private readonly IUserRepository _userRepository;
    
    public async Task<IActionResult> Get(int id)
    {
        var user = await _userRepository.GetById(id);
        return user != null ? Ok(user) : NotFound();
    }
}

Abstraction Decision Framework

High Complexity + Multiple Implementations = Abstract
├── Payment gateways: ✅ Abstract
├── Cloud storage providers: ✅ Abstract
├── Authentication providers: ✅ Abstract
└── Message queues: ✅ Abstract

Low Complexity + Single Implementation = Don't Abstract
├── Simple CRUD operations: ❌ Keep direct
├── Internal utilities: ❌ Keep direct
├── Stable domain logic: ❌ Keep direct
└── One-time integrations: ❌ Keep direct

3. Inheritance

“Create class hierarchies where subclasses inherit behavior from parent classes.”

The Inheritance Trap

Inheritance is the most overused and misunderstood OOP principle. At the architect level:

  • Composition usually beats inheritance
  • Deep hierarchies are maintenance nightmares
  • Inheritance creates tight coupling

When Inheritance Makes Sense

✅ Appropriate Use (Behavioral Extension):

// Framework base class with Template Method pattern
public abstract class BackgroundService
{
    private CancellationTokenSource _stoppingCts;
    
    // Template method - defines algorithm structure
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        
        await OnStarting(cancellationToken);
        await ExecuteAsync(_stoppingCts.Token);
    }
    
    public async Task StopAsync(CancellationToken cancellationToken)
    {
        _stoppingCts.Cancel();
        await OnStopping(cancellationToken);
    }
    
    // Subclasses override this to provide custom behavior
    protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
    
    protected virtual Task OnStarting(CancellationToken cancellationToken) 
        => Task.CompletedTask;
    
    protected virtual Task OnStopping(CancellationToken cancellationToken) 
        => Task.CompletedTask;
}

// Concrete implementations
public class OrderProcessorService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await ProcessPendingOrders();
            await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
        }
    }
}

public class EmailSenderService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await SendQueuedEmails();
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

Why This Works:

  • Common lifecycle management in base class
  • Specific behavior in subclasses
  • Framework handles infrastructure concerns
  • Clean separation of concerns

When Inheritance Creates Problems

❌ Inheritance for Code Reuse:

// Using inheritance just to share code
public class Employee
{
    public string Name { get; set; }
    public string Email { get; set; }
    public string PhoneNumber { get; set; }
    
    public void SendEmail(string message)
    {
        // Email sending logic
    }
    
    public void SendSMS(string message)
    {
        // SMS sending logic
    }
}

// Customer inherits from Employee to get communication methods
public class Customer : Employee
{
    public decimal AccountBalance { get; set; }
    public List<Order> Orders { get; set; }
}

// Now Customer "IS-A" Employee - semantically wrong!

Problems:

  • Violated IS-A relationship: Customer is NOT an Employee
  • Tight coupling: Changes to Employee affect Customer
  • Brittle hierarchy: Can’t change inheritance later without breaking everything
  • Misleading domain model: Doesn’t represent real business concepts

✅ Use Composition Instead:

// Shared behavior extracted to separate service
public interface INotificationService
{
    Task SendEmail(string recipient, string message);
    Task SendSMS(string phoneNumber, string message);
}

// Employee uses notification service
public class Employee
{
    private readonly INotificationService _notificationService;
    
    public string Name { get; set; }
    public string Email { get; set; }
    public string PhoneNumber { get; set; }
    
    public Employee(INotificationService notificationService)
    {
        _notificationService = notificationService;
    }
    
    public async Task NotifyByEmail(string message)
    {
        await _notificationService.SendEmail(Email, message);
    }
}

// Customer also uses notification service
public class Customer
{
    private readonly INotificationService _notificationService;
    
    public string Name { get; set; }
    public string Email { get; set; }
    public decimal AccountBalance { get; set; }
    
    public Customer(INotificationService notificationService)
    {
        _notificationService = notificationService;
    }
    
    public async Task NotifyByEmail(string message)
    {
        await _notificationService.SendEmail(Email, message);
    }
}

Deep Inheritance Hierarchies

❌ Inheritance Hell:

// 7 levels deep - maintenance nightmare
public class Entity { }
public class DomainEntity : Entity { }
public class AuditableEntity : DomainEntity { }
public class Person : AuditableEntity { }
public class Employee : Person { }
public class Manager : Employee { }
public class SeniorManager : Manager { }

// To understand SeniorManager, you need to understand 6 parent classes
// Changes ripple through entire hierarchy
// Fragile Base Class Problem: changing Entity can break everything

✅ Shallow Hierarchy:

// Maximum 2-3 levels
public abstract class Entity
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
}

public class Employee : Entity
{
    public string Name { get; set; }
    public string Department { get; set; }
    public EmployeeType Type { get; set; } // Use enum or value object
}

// Use composition for specialized behavior
public class ManagerCapabilities
{
    public List<Employee> DirectReports { get; set; }
    public decimal ApprovalLimit { get; set; }
}

public class SeniorManagerCapabilities
{
    public List<Manager> Managers { get; set; }
    public List<string> Departments { get; set; }
}

Inheritance vs Composition Decision Tree

Do subclasses share behavior AND satisfy "IS-A" relationship?
├── Yes → Consider inheritance
│   └── Is hierarchy shallow (max 2-3 levels)?
│       ├── Yes → Inheritance OK
│       └── No → Use composition
└── No → Use composition

Examples:
├── Button IS-A Control: ✅ Inheritance
├── Rectangle IS-A Shape: ✅ Inheritance  
├── Manager IS-A Employee: ⚠️ Use composition (specialization)
└── Car HAS-A Engine: ✅ Composition

4. Polymorphism

“Use a single interface to represent different underlying forms.”

Strategic Polymorphism

Polymorphism enables plugin architectures and strategy patterns at scale.

✅ Polymorphic Plugin System:

// Define behavior contract
public interface IOrderProcessor
{
    string ProcessorName { get; }
    bool CanProcess(Order order);
    Task<ProcessingResult> Process(Order order);
}

// Multiple implementations
public class StandardOrderProcessor : IOrderProcessor
{
    public string ProcessorName => "Standard";
    
    public bool CanProcess(Order order) 
        => order.Total < 1000 && order.Items.Count < 10;
    
    public async Task<ProcessingResult> Process(Order order)
    {
        // Standard processing logic
        await ValidateInventory(order);
        await ChargePayment(order);
        return ProcessingResult.Success();
    }
}

public class BulkOrderProcessor : IOrderProcessor
{
    public string ProcessorName => "Bulk";
    
    public bool CanProcess(Order order) 
        => order.Items.Count >= 10;
    
    public async Task<ProcessingResult> Process(Order order)
    {
        // Bulk processing with different logic
        await ValidateInventoryBulk(order);
        await ApplyBulkDiscount(order);
        await ChargePayment(order);
        return ProcessingResult.Success();
    }
}

public class HighValueOrderProcessor : IOrderProcessor
{
    public string ProcessorName => "High-Value";
    
    public bool CanProcess(Order order) 
        => order.Total >= 1000;
    
    public async Task<ProcessingResult> Process(Order order)
    {
        // Additional verification for high-value orders
        await VerifyCustomerCredit(order);
        await FraudCheck(order);
        await ValidateInventory(order);
        await ChargePayment(order);
        await NotifyAccountManager(order);
        return ProcessingResult.Success();
    }
}

// Processor orchestrator uses polymorphism
public class OrderProcessingService
{
    private readonly IEnumerable<IOrderProcessor> _processors;
    
    public OrderProcessingService(IEnumerable<IOrderProcessor> processors)
    {
        // DI automatically discovers all implementations
        _processors = processors;
    }
    
    public async Task<ProcessingResult> ProcessOrder(Order order)
    {
        // Select appropriate processor polymorphically
        var processor = _processors.FirstOrDefault(p => p.CanProcess(order));
        
        if (processor == null)
            throw new InvalidOperationException("No processor available for order");
        
        return await processor.Process(order);
    }
}

Architectural Benefits:

  • Add new processors without modifying orchestrator
  • Each processor encapsulates its own rules
  • Easy to test each processor independently
  • Clear separation of concerns

Polymorphism in Distributed Systems

✅ Service-Level Polymorphism:

// Abstract notification behavior
public interface INotificationProvider
{
    string ProviderName { get; }
    Task<SendResult> Send(NotificationRequest request);
}

// Email provider
public class EmailNotificationProvider : INotificationProvider
{
    public string ProviderName => "Email";
    
    public async Task<SendResult> Send(NotificationRequest request)
    {
        // SMTP logic
    }
}

// SMS provider
public class SmsNotificationProvider : INotificationProvider
{
    public string ProviderName => "SMS";
    
    public async Task<SendResult> Send(NotificationRequest request)
    {
        // Twilio API logic
    }
}

// Push notification provider
public class PushNotificationProvider : INotificationProvider
{
    public string ProviderName => "Push";
    
    public async Task<SendResult> Send(NotificationRequest request)
    {
        // Firebase Cloud Messaging logic
    }
}

// Slack provider
public class SlackNotificationProvider : INotificationProvider
{
    public string ProviderName => "Slack";
    
    public async Task<SendResult> Send(NotificationRequest request)
    {
        // Slack webhook logic
    }
}

// Notification service uses polymorphism for multi-channel delivery
public class NotificationService
{
    private readonly Dictionary<NotificationChannel, INotificationProvider> _providers;
    
    public NotificationService(IEnumerable<INotificationProvider> providers)
    {
        _providers = providers.ToDictionary(
            p => Enum.Parse<NotificationChannel>(p.ProviderName), 
            p => p);
    }
    
    public async Task<NotificationResult> SendNotification(
        NotificationRequest request, 
        NotificationChannel[] channels)
    {
        var tasks = channels
            .Select(channel => _providers[channel].Send(request))
            .ToArray();
        
        var results = await Task.WhenAll(tasks);
        
        return new NotificationResult
        {
            SuccessCount = results.Count(r => r.Success),
            FailureCount = results.Count(r => !r.Success)
        };
    }
}

When Polymorphism Adds Complexity

❌ Over-Engineering Simple Logic:

// Polymorphism for formatting - overkill
public interface IUserNameFormatter
{
    string Format(User user);
}

public class FirstLastFormatter : IUserNameFormatter
{
    public string Format(User user) => $"{user.FirstName} {user.LastName}";
}

public class LastFirstFormatter : IUserNameFormatter
{
    public string Format(User user) => $"{user.LastName}, {user.FirstName}";
}

public class InitialsFormatter : IUserNameFormatter
{
    public string Format(User user) => $"{user.FirstName[0]}{user.LastName[0]}";
}

// 3 classes for simple string formatting

✅ Simple Methods Instead:

public static class UserNameFormatter
{
    public static string FirstLast(User user) 
        => $"{user.FirstName} {user.LastName}";
    
    public static string LastFirst(User user) 
        => $"{user.LastName}, {user.FirstName}";
    
    public static string Initials(User user) 
        => $"{user.FirstName[0]}{user.LastName[0]}";
}

// Use directly where needed
var display = UserNameFormatter.FirstLast(user);

OOP Anti-Patterns at Scale

1. God Objects

// ❌ God Object - knows and does everything
public class OrderManager
{
    // Properties - too many responsibilities
    public DbContext Database { get; set; }
    public IEmailService EmailService { get; set; }
    public IPaymentGateway PaymentGateway { get; set; }
    public IInventoryService InventoryService { get; set; }
    public IShippingService ShippingService { get; set; }
    public INotificationService NotificationService { get; set; }
    public ILogger Logger { get; set; }
    public IConfiguration Configuration { get; set; }
    
    // 50+ methods handling everything related to orders
    public async Task CreateOrder() { }
    public async Task UpdateOrder() { }
    public async Task CancelOrder() { }
    public async Task ProcessPayment() { }
    public async Task RefundPayment() { }
    public async Task ValidateInventory() { }
    public async Task ReserveInventory() { }
    public async Task ReleaseInventory() { }
    public async Task CalculateShipping() { }
    public async Task GenerateInvoice() { }
    public async Task SendConfirmationEmail() { }
    public async Task SendShippingNotification() { }
    public async Task GeneratePackingSlip() { }
    public async Task UpdateOrderStatus() { }
    public async Task ApplyDiscount() { }
    public async Task CalculateTax() { }
    // ... 35 more methods
}

Solution: Break into cohesive classes with single responsibilities.

2. Anemic Domain Model

// ❌ All data, no behavior
public class Order
{
    public int Id { get; set; }
    public List<OrderItem> Items { get; set; }
    public OrderStatus Status { get; set; }
    public decimal Total { get; set; }
}

// All logic in services - just procedural programming
public class OrderService
{
    public void Calculate(Order order) { }
    public void Validate(Order order) { }
    public void Submit(Order order) { }
}

Solution: Put behavior where data lives (rich domain model).

3. Yo-Yo Problem

// ❌ Method calls bounce up and down inheritance hierarchy
public class GrandParent
{
    public virtual void DoSomething() 
    {
        Step1();
        Step2();
    }
    protected virtual void Step1() { }
    protected virtual void Step2() { }
}

public class Parent : GrandParent
{
    protected override void Step1() 
    {
        base.Step1(); // Call grandparent
        // Parent logic
    }
}

public class Child : Parent
{
    public override void DoSomething()
    {
        base.DoSomething(); // Call parent
        Step3();
    }
    
    protected override void Step2()
    {
        base.Step2(); // Call parent
        // Child logic
    }
    
    private void Step3() { }
}

// To understand Child.DoSomething(), you trace through 3 classes

Solution: Favor composition or keep hierarchies flat.

4. Circle-Ellipse Problem

// ❌ Violates Liskov Substitution Principle
public class Ellipse
{
    public virtual double MajorAxis { get; set; }
    public virtual double MinorAxis { get; set; }
    public double Area => Math.PI * MajorAxis * MinorAxis;
}

public class Circle : Ellipse
{
    private double _radius;
    
    public double Radius
    {
        get => _radius;
        set
        {
            _radius = value;
            base.MajorAxis = value;
            base.MinorAxis = value;
        }
    }
    
    public override double MajorAxis
    {
        get => _radius;
        set => Radius = value;
    }
    
    public override double MinorAxis
    {
        get => _radius;
        set => Radius = value;
    }
}

// Breaks when you do:
void ResizeEllipse(Ellipse ellipse)
{
    ellipse.MajorAxis = 10;
    ellipse.MinorAxis = 5;
    // If ellipse is Circle, this creates inconsistent state
}

Solution: Don’t use inheritance when mathematical IS-A relationship doesn’t hold.

OOP in Modern Architectures

Microservices and OOP

Traditional OOP Hierarchy:
└── PaymentService
    ├── CreditCardPayment
    ├── BankTransferPayment
    └── CryptoPayment

Microservices Approach:
├── CreditCardPaymentService (independent service)
├── BankTransferPaymentService (independent service)
└── CryptoPaymentService (independent service)

OOP principles apply WITHIN each service, not ACROSS services

Event-Driven Systems

// OOP within bounded context
public class Order
{
    private readonly List<IDomainEvent> _events = new();
    
    public void Submit()
    {
        Status = OrderStatus.Submitted;
        _events.Add(new OrderSubmittedEvent(Id, CustomerId, Total));
    }
    
    public IReadOnlyList<IDomainEvent> GetEvents() => _events;
}

// Polymorphic event handlers
public interface IEventHandler<TEvent> where TEvent : IDomainEvent
{
    Task Handle(TEvent @event);
}

public class OrderSubmittedEmailHandler : IEventHandler<OrderSubmittedEvent>
{
    public async Task Handle(OrderSubmittedEvent @event)
    {
        await SendConfirmationEmail(@event);
    }
}

public class OrderSubmittedInventoryHandler : IEventHandler<OrderSubmittedEvent>
{
    public async Task Handle(OrderSubmittedEvent @event)
    {
        await ReserveInventory(@event);
    }
}

The Pragmatic OOP Decision Matrix

Principle Apply When Skip When
Encapsulation Complex invariants, state management Simple DTOs, configuration objects
Abstraction Multiple implementations, high variance Single implementation, stable code
Inheritance Framework base classes, IS-A relationships Code reuse only, deep hierarchies
Polymorphism Plugin architectures, strategy patterns Simple conditional logic, formatting

Key Takeaways

  1. OOP is a tool

    • Use it where it adds value
    • Mix with functional and procedural approaches
    • Optimize for readability and maintainability
  2. Favor composition over inheritance

    • Inheritance creates tight coupling
    • Composition provides flexibility
    • Keep hierarchies shallow (max 2-3 levels)
  3. Encapsulate what changes

    • Hide complexity behind stable interfaces
    • Protect invariants through controlled mutations
    • Skip encapsulation for simple data transfer
  4. Abstract strategically

    • Add abstraction layers when there’s variance
    • Don’t abstract stable, simple code
    • Each layer must add value
  5. Polymorphism enables extensibility

    • Use for plugin architectures
    • Good for strategy patterns
    • Overkill for simple conditional logic
  6. Context matters more than principles

    • Small teams → Less abstraction
    • Large teams → Clear boundaries
    • Microservices → Bounded contexts
    • Monoliths → Rich domain models

See also