Object-oriented programming often feels unnecessarily abstract in academic settings, where concepts like encapsulation and access control are introduced before their practical value is obvious. This perspective tends to change in professional environments, where these same concepts become essential for organizing complexity, enforcing correctness, and enabling collaboration. Working with modern .NET projects highlighted how object-oriented design becomes intuitive when the language and tooling support it naturally.
Quick History: The Origins of C# and .NET
C# emerged in the early 2000s during a period when Java was the dominant choice for enterprise development. Java introduced important ideas such as managed memory and platform independence, but its ecosystem evolved conservatively and often relied on extensive configuration and external frameworks. Designed by Anders Hejlsberg, C# combined established object-oriented principles with a focus on clarity, expressiveness, and strong tooling.
The Modern .NET Platform
Today’s .NET platform is a unified and mature ecosystem capable of supporting web, desktop, mobile, and cloud-based applications under a consistent programming model. The platform emphasizes performance, modern language features, and cloud readiness, while reducing the fragmentation that often complicates large systems.
Java and C# are both well-established languages with strong industry adoption and long-term viability. Practical experience with both, however, reveals differences in emphasis rather than capability. C# places particular focus on cohesive tooling, steady language evolution, and deep integration across its ecosystem. These choices result in object-oriented patterns that feel pragmatic rather than ceremonial, especially when working on large systems where clarity and maintainability matter more than theoretical purity.
Core Principles of Object-Oriented Programming
The foundational principles of object-oriented programming are often presented with more ceremony than necessary. In practice, they are straightforward ideas that become clear once applied to real systems. Much like mathematical axioms, their value lies not in memorization but in repeated use and practical consequence.
Together, these principles form the conceptual foundation that explains why object-oriented systems scale more effectively than ad-hoc designs.
1. Encapsulation: Managing Access to State
Encapsulation is the idea of grouping data with the operations that act on it, while carefully controlling how that data can be modified. Rather than allowing unrestricted access, encapsulation enforces rules that preserve correctness and invariants. In other words, it prevents a system from entering states that would make even the most optimistic debugger uncomfortable.
In C#, encapsulation is commonly implemented using properties. The following example illustrates a simple Employee class:
public class Employee
{
private string _name;
private decimal _salary;
private readonly string _employeeId;
// Property with validation
public string Name
{
get => _name;
set => _name = !string.IsNullOrWhiteSpace(value)
? value
: throw new ArgumentException("Name can't be empty");
}
public decimal Salary
{
get => _salary;
set
{
if (value < 0)
throw new ArgumentOutOfRangeException("Salary can't be negative");
_salary = value;
}
}
// Read-only property
public string EmployeeId => _employeeId;
// Auto-property with private setter
public DateTime HireDate { get; private set; }
// Required property (C# 11)
public required string Department { get; init; }
public Employee(string name, decimal salary, string employeeId)
{
_name = !string.IsNullOrWhiteSpace(name)
? name
: throw new ArgumentException("Name can't be empty");
_salary = salary >= 0
? salary
: throw new ArgumentOutOfRangeException("Salary must be non-negative");
_employeeId = employeeId ?? throw new ArgumentNullException(nameof(employeeId));
HireDate = DateTime.UtcNow;
}
public void ApplyRaise(decimal percentage)
{
if (percentage < 0 || percentage > 100)
throw new ArgumentOutOfRangeException("Percentage must be between 0 and 100");
_salary *= (1 + percentage / 100);
}
// Computed property
public decimal AnnualBonus => _salary * 0.10m;
}So internal fields remain private, while controlled access is provided through properties that enforce validation. This ensures that invalid states, such as negative salaries or missing names, are prevented by design rather than discovered during testing or, worse, in production.
Modern C# makes this even cleaner with record types:
// Immutable data transfer object
public record EmployeeDto(
string EmployeeId,
string Name,
string Department,
decimal Salary
);
// Primary constructors (C# 12)
public class EmployeeService(ILogger<EmployeeService> logger, IEmployeeRepository repository)
{
public async Task<EmployeeDto> GetEmployeeAsync(string employeeId)
{
logger.LogInformation("Fetching employee {EmployeeId}", employeeId);
var employee = await repository.GetByIdAsync(employeeId);
return new EmployeeDto(
employee.EmployeeId,
employee.Name,
employee.Department,
employee.Salary
);
}
}Why does this matter? Because when you're working on a big codebase with other people, you need to make sure data stays consistent. If anyone could just set employee.Salary = -1000, your system would break. Encapsulation prevents that. This becomes increasingly important in large, collaborative codebases, where assumptions about state consistency cannot be left to convention. Encapsulation ensures that correctness is enforced mechanically, which is far more reliable than hoping every contributor remembers the rules.
2. Inheritance: When Things Share Behavior
Inheritance is the "is-a" relationship. A dog is an animal. A car is a vehicle. In code, this means a class can inherit from another class and get all its functionality.
Here's a realistic example:
public abstract class Employee
{
public string EmployeeId { get; }
public string Name { get; set; }
public string Department { get; set; }
public DateTime HireDate { get; protected set; }
protected Employee(string employeeId, string name, string department)
{
EmployeeId = employeeId;
Name = name;
Department = department;
HireDate = DateTime.UtcNow;
}
// Template method - defines the structure
public decimal CalculateTotalCompensation()
{
var baseSalary = GetBaseSalary();
var benefits = CalculateBenefits();
var bonus = CalculateBonus();
return baseSalary + benefits + bonus;
}
// Must be implemented by derived classes
public abstract decimal GetBaseSalary();
// Can be overridden
protected virtual decimal CalculateBenefits() => GetBaseSalary() * 0.15m;
protected virtual decimal CalculateBonus() => 0m;
}
public class FullTimeEmployee : Employee
{
public decimal AnnualSalary { get; set; }
public FullTimeEmployee(string employeeId, string name, string department, decimal annualSalary)
: base(employeeId, name, department)
{
AnnualSalary = annualSalary;
}
public override decimal GetBaseSalary() => AnnualSalary / 12;
protected override decimal CalculateBonus()
{
var yearsOfService = (DateTime.UtcNow - HireDate).TotalDays / 365.25;
var bonusPercentage = yearsOfService switch
{
< 1 => 0.05m,
< 3 => 0.10m,
< 5 => 0.15m,
_ => 0.20m
};
return AnnualSalary * bonusPercentage;
}
}
public class PartTimeEmployee : Employee
{
public decimal HourlyRate { get; set; }
public int HoursWorked { get; set; }
public PartTimeEmployee(string employeeId, string name, string department,
decimal hourlyRate, int hoursWorked)
: base(employeeId, name, department)
{
HourlyRate = hourlyRate;
HoursWorked = hoursWorked;
}
public override decimal GetBaseSalary() => HourlyRate * HoursWorked;
protected override decimal CalculateBenefits()
{
// Part-time gets half benefits if they work 20+ hours
return HoursWorked >= 20 ? base.CalculateBenefits() * 0.5m : 0m;
}
}But here's the thing: inheritance can be overused. A lot of times, composition (using interfaces) is better. Here's a more flexible approach:
public interface IPayable
{
decimal CalculatePay();
string PaymentSchedule { get; }
}
public interface IBenefitsEligible
{
decimal CalculateBenefits() => 0m;
bool IsEligibleForHealthcare() => true;
}
public class ModernEmployee : IPayable, IBenefitsEligible
{
private readonly ICompensationStrategy _compensationStrategy;
public string EmployeeId { get; init; }
public string Name { get; init; }
public string PaymentSchedule => _compensationStrategy.Schedule;
public ModernEmployee(ICompensationStrategy compensationStrategy)
{
_compensationStrategy = compensationStrategy;
}
public decimal CalculatePay() => _compensationStrategy.Calculate();
}Inheritance works best when there is a clear and stable conceptual relationship, shared implementation logic, and a shallow hierarchy. Composition and interfaces are often a better choice when behavior needs to vary dynamically, when flexibility is required, or when testability is a priority. Favoring composition tends to reduce coupling and results in systems that are easier to reason about over time.
3. Polymorphism: Many Forms, One Interface
Polymorphism is a fancy word for "different objects can respond to the same message in their own way." In an academic term, it refers to the ability of different objects to respond to the same operation in different ways. While the term may sound abstract, the idea is simple: consistent interfaces with variable behavior. In mathematical terms, it is function evaluation with different underlying implementations.
There are two types:
Compile-time polymorphism is method overloading:
public class Calculator
{
public decimal Calculate(decimal amount, decimal rate)
{
return amount * rate;
}
public decimal Calculate(decimal amount, decimal rate, int periods)
{
return amount * rate * periods;
}
public decimal Calculate(decimal amount, decimal rate, int periods, bool compound)
{
return compound
? amount * (decimal)Math.Pow((double)(1 + rate), periods)
: Calculate(amount, rate, periods);
}
}Same method name, different parameters. The compiler figures out which one to call.
You can also overload operators, which is pretty cool:
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money operator +(Money left, Money right)
{
if (left.Currency != right.Currency)
throw new InvalidOperationException("Can't add different currencies");
return new Money(left.Amount + right.Amount, left.Currency);
}
public static Money operator *(Money money, decimal multiplier)
{
return new Money(money.Amount * multiplier, money.Currency);
}
}
// Now you can do:
var total = new Money(100, "USD") + new Money(50, "USD");
var doubled = total * 2;Run-time polymorphism is the more interesting one. It's when the actual method that gets called is determined at runtime based on the object's type:
public abstract class PaymentProcessor
{
protected readonly ILogger _logger;
protected PaymentProcessor(ILogger logger)
{
_logger = logger;
}
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
{
try
{
_logger.LogInformation("Processing payment of {Amount}", request.Amount);
await ValidateRequestAsync(request);
var result = await ExecutePaymentAsync(request);
await LogTransactionAsync(request, result);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Payment processing failed");
throw;
}
}
protected virtual async Task ValidateRequestAsync(PaymentRequest request)
{
if (request.Amount <= 0)
throw new ArgumentException("Payment amount must be positive");
await Task.CompletedTask;
}
protected abstract Task<PaymentResult> ExecutePaymentAsync(PaymentRequest request);
protected virtual async Task LogTransactionAsync(PaymentRequest request, PaymentResult result)
{
_logger.LogInformation("Payment {TransactionId} completed", result.TransactionId);
await Task.CompletedTask;
}
}
public class CreditCardProcessor : PaymentProcessor
{
private readonly ICreditCardGateway _gateway;
public CreditCardProcessor(ILogger<CreditCardProcessor> logger, ICreditCardGateway gateway)
: base(logger)
{
_gateway = gateway;
}
protected override async Task<PaymentResult> ExecutePaymentAsync(PaymentRequest request)
{
_logger.LogDebug("Processing credit card payment");
return await _gateway.ChargeAsync(request.CardNumber, request.Amount);
}
}
public class PayPalProcessor : PaymentProcessor
{
private readonly IPayPalClient _client;
public PayPalProcessor(ILogger<PayPalProcessor> logger, IPayPalClient client)
: base(logger)
{
_client = client;
}
protected override async Task<PaymentResult> ExecutePaymentAsync(PaymentRequest request)
{
_logger.LogDebug("Processing PayPal payment");
return await _client.CreatePaymentAsync(request.Email, request.Amount);
}
}Now I can write code that just uses PaymentProcessor, and it works for any payment type:
public async Task ProcessPayment(PaymentProcessor processor, PaymentRequest request)
{
var result = await processor.ProcessPaymentAsync(request);
Console.WriteLine($"Transaction {result.TransactionId} successful!");
}The key advantage is that new payment processors can be added without modifying existing logic, preserving correctness while enabling extension.
4. Abstraction: Hiding the Messy Details
Abstraction focuses on exposing only what a user of a component needs to know, while hiding unnecessary implementation details. Much like an API for a mathematical function, the caller cares about inputs and outputs, not the internal steps required to compute the result.
Here's a practical example with database connections:
public abstract class DatabaseConnection : IDisposable
{
protected string ConnectionString { get; }
protected bool IsConnected { get; private set; }
private readonly ILogger _logger;
protected DatabaseConnection(string connectionString, ILogger logger)
{
ConnectionString = connectionString;
_logger = logger;
}
public async Task<TResult> ExecuteAsync<TResult>(Func<Task<TResult>> operation)
{
try
{
await OpenConnectionAsync();
return await operation();
}
catch (Exception ex)
{
_logger.LogError(ex, "Database operation failed");
await HandleErrorAsync(ex);
throw;
}
finally
{
await CloseConnectionAsync();
}
}
protected abstract Task OpenConnectionAsync();
protected abstract Task CloseConnectionAsync();
protected abstract Task HandleErrorAsync(Exception exception);
public void Dispose()
{
if (IsConnected)
CloseConnectionAsync().GetAwaiter().GetResult();
}
}
public class SqlServerConnection : DatabaseConnection
{
private SqlConnection? _connection;
public SqlServerConnection(string connectionString, ILogger<SqlServerConnection> logger)
: base(connectionString, logger)
{
}
protected override async Task OpenConnectionAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
IsConnected = true;
}
protected override async Task CloseConnectionAsync()
{
if (_connection != null)
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
_connection = null;
IsConnected = false;
}
}
protected override async Task HandleErrorAsync(Exception exception)
{
if (exception is SqlException sqlEx)
_logger.LogError("SQL Error {Number}: {Message}", sqlEx.Number, sqlEx.Message);
await Task.CompletedTask;
}
}Code using this doesn't care if it's SQL Server, PostgreSQL, or whatever. It just calls ExecuteAsync and the right thing happens.
Interfaces are even more flexible:
public interface IMessagePublisher
{
Task PublishAsync<T>(T message, CancellationToken cancellationToken = default) where T : class;
}
public class ServiceBusPublisher : IMessagePublisher
{
private readonly ServiceBusClient _client;
private readonly string _queueName;
public ServiceBusPublisher(ServiceBusClient client, string queueName)
{
_client = client;
_queueName = queueName;
}
public async Task PublishAsync<T>(T message, CancellationToken cancellationToken = default) where T : class
{
var sender = _client.CreateSender(_queueName);
var json = JsonSerializer.Serialize(message);
var serviceBusMessage = new ServiceBusMessage(json);
await sender.SendMessageAsync(serviceBusMessage, cancellationToken);
}
}
public class RabbitMqPublisher : IMessagePublisher
{
private readonly IConnection _connection;
private readonly string _exchangeName;
public RabbitMqPublisher(IConnection connection, string exchangeName)
{
_connection = connection;
_exchangeName = exchangeName;
}
public async Task PublishAsync<T>(T message, CancellationToken cancellationToken = default) where T : class
{
using var channel = _connection.CreateModel();
var json = JsonSerializer.Serialize(message);
var body = Encoding.UTF8.GetBytes(json);
channel.BasicPublish(
exchange: _exchangeName,
routingKey: typeof(T).Name,
body: body
);
await Task.CompletedTask;
}
}Now your app can swap between Azure Service Bus and RabbitMQ without changing any of the code that publishes messages. That's the power of abstraction.
SOLID Principles (The Rules That Actually Help)
Once you get OOP basics, SOLID principles are the next level. They're guidelines for writing code that doesn't turn into a nightmare as it grows.
Single Responsibility Principle: A class should only have one reason to change.
// Bad - this class does too much
public class OrderProcessor
{
public void ProcessOrder(Order order)
{
ValidateOrder(order);
CalculateTotal(order);
SaveToDatabase(order);
SendConfirmationEmail(order);
}
}
// Good - separated responsibilities
public class OrderProcessor
{
private readonly OrderValidator _validator;
private readonly OrderCalculator _calculator;
private readonly OrderRepository _repository;
private readonly OrderNotificationService _notificationService;
public async Task ProcessAsync(Order order)
{
_validator.Validate(order);
order.Total = _calculator.CalculateTotal(order);
await _repository.SaveAsync(order);
await _notificationService.SendConfirmationAsync(order);
}
}Open/Closed Principle: Open for extension, closed for modification.
// Add new discount types without changing existing code
public abstract class DiscountStrategy
{
public abstract decimal ApplyDiscount(decimal amount);
}
public class PercentageDiscount : DiscountStrategy
{
private readonly decimal _percentage;
public PercentageDiscount(decimal percentage) => _percentage = percentage;
public override decimal ApplyDiscount(decimal amount) => amount * (1 - _percentage / 100);
}
public class FixedAmountDiscount : DiscountStrategy
{
private readonly decimal _amount;
public FixedAmountDiscount(decimal amount) => _amount = amount;
public override decimal ApplyDiscount(decimal amount) => Math.Max(0, amount - _amount);
}Liskov Substitution Principle: Subtypes must be substitutable for their base types.
// Good - both shapes work the same way
public abstract class Shape
{
public abstract double CalculateArea();
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double CalculateArea() => Width * Height;
}
public class Square : Shape
{
public double Side { get; set; }
public override double CalculateArea() => Side * Side;
}Interface Segregation: Don't force classes to implement methods they don't need.
// Bad - forces robots to implement Eat and Sleep
public interface IWorker
{
void Work();
void Eat();
void Sleep();
}
// Good - separate interfaces
public interface IWorkable { void Work(); }
public interface IFeedable { void Eat(); }
public interface IRestable { void Sleep(); }
public class Human : IWorkable, IFeedable, IRestable
{
public void Work() { }
public void Eat() { }
public void Sleep() { }
}
public class Robot : IWorkable
{
public void Work() { }
// Robots don't need Eat or Sleep
}Dependency Inversion: Depend on abstractions, not concrete implementations.
// Depend on IDataStore interface, not specific implementations
public interface IDataStore
{
Task<T> GetAsync<T>(string key);
Task SetAsync<T>(string key, T value);
}
public class CacheService
{
private readonly IDataStore _dataStore; // Could be Redis, SQL, whatever
public CacheService(IDataStore dataStore)
{
_dataStore = dataStore;
}
public async Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory)
{
var cached = await _dataStore.GetAsync<T>(key);
if (cached != null) return cached;
var value = await factory();
await _dataStore.SetAsync(key, value);
return value;
}
}Cool Modern C# Features
Records make immutable objects way easier:
public record Customer(
Guid Id,
string Name,
string Email,
Address ShippingAddress
);
// Update with 'with' expression
var updatedCustomer = customer with { Email = "newemail@example.com" };Pattern matching makes polymorphic code cleaner:
public abstract record PaymentMethod;
public record CreditCard(string CardNumber, DateTime Expiry) : PaymentMethod;
public record BankTransfer(string AccountNumber) : PaymentMethod;
public record DigitalWallet(string WalletId, string Provider) : PaymentMethod;
public async Task<PaymentResult> ProcessAsync(PaymentMethod payment, decimal amount)
{
return payment switch
{
CreditCard { Expiry: var exp } when exp > DateTime.UtcNow
=> await ProcessCreditCardAsync(payment, amount),
BankTransfer transfer
=> await ProcessBankTransferAsync(transfer, amount),
DigitalWallet { Provider: "PayPal" } wallet
=> await ProcessPayPalAsync(wallet, amount),
CreditCard { Expiry: var exp } when exp <= DateTime.UtcNow
=> throw new InvalidOperationException("Card expired"),
_ => throw new NotSupportedException($"Unsupported payment method")
};
}Required members (C# 11) make sure you don't forget to initialize properties:
public class Order
{
public required Guid OrderId { get; init; }
public required string CustomerEmail { get; init; }
public required List<OrderItem> Items { get; init; }
public DateTime OrderDate { get; init; } = DateTime.UtcNow;
}
// Compiler error if you forget any required property
var order = new Order
{
OrderId = Guid.NewGuid(),
CustomerEmail = "customer@example.com",
Items = new List<OrderItem>()
};Performance Stuff
Structs vs Classes: Classes are allocated on the heap (slower, garbage collected). Structs are allocated on the stack (faster, no GC pressure).
// For small, frequently created objects, use structs
public readonly struct Point2D
{
public double X { get; }
public double Y { get; }
public Point2D(double x, double y)
{
X = x;
Y = y;
}
public double DistanceFrom(Point2D other)
{
var dx = X - other.X;
var dy = Y - other.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
}Span for zero-allocation string processing:
public int CountVowels(ReadOnlySpan<char> text)
{
var count = 0;
foreach (var c in text)
{
if (c is 'a' or 'e' or 'i' or 'o' or 'u'
or 'A' or 'E' or 'I' or 'O' or 'U')
count++;
}
return count;
}Final Thoughts
Object-oriented programming is a set of tools for managing complexity in large systems. Encapsulation, inheritance, polymorphism, and abstraction provide structure, while the SOLID principles help ensure that this structure remains adaptable over time. C# presents these ideas in a way that feels natural and cohesive, with language features that encourage clarity and correctness by default. Modern constructs such as properties, records, and pattern matching reduce boilerplate and allow developers to focus on intent rather than ceremony.
The most important lesson, however, is balance. Not every problem requires abstraction, and not every class needs an interface. Good design lies in knowing when structure adds value and when simplicity is sufficient.