Object-Oriented Programming in C#
Classes, constructors, properties, encapsulation, inheritance, and polymorphism in C#.
Defining a Class
A class is a blueprint for creating objects. It bundles data (fields and properties) with behavior (methods) into a single unit. This is the core idea of object-oriented programming — instead of having separate data structures and functions that operate on them, you group related state and logic together. The BankAccount example below shows how a well-designed class controls its own state and enforces its own rules.
public class BankAccount
{
// Fields — private, so external code cannot bypass the class's rules
private decimal _balance;
private readonly string _accountNumber;
private readonly List<string> _transactions = new();
// Auto-implemented property — compiler generates the backing field
public string Owner { get; set; }
// Read-only property — exposes _balance without allowing external modification
public decimal Balance => _balance;
// Property with private setter — only this class can change OverdraftLimit
public decimal OverdraftLimit { get; private set; }
// Constructor — sets up the object in a valid initial state
public BankAccount(string owner, string accountNumber, decimal initialBalance = 0)
{
Owner = owner ?? throw new ArgumentNullException(nameof(owner));
_accountNumber = accountNumber;
_balance = initialBalance;
OverdraftLimit = 0;
}
// Methods — the only ways to modify _balance; all validation lives here
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount), "Must be positive");
_balance += amount;
_transactions.Add($"+{amount:C} on {DateTime.UtcNow:yyyy-MM-dd}");
}
public bool Withdraw(decimal amount)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount), "Must be positive");
if (_balance - amount < -OverdraftLimit)
return false; // insufficient funds — caller checks the return value
_balance -= amount;
_transactions.Add($"-{amount:C} on {DateTime.UtcNow:yyyy-MM-dd}");
return true;
}
// Return a read-only view — callers can iterate but not modify directly
public IReadOnlyList<string> GetTransactions() => _transactions.AsReadOnly();
public override string ToString() =>
$"Account {_accountNumber} | Owner: {Owner} | Balance: {Balance:C}";
}
Object Initialization
C# offers several ways to create and initialize objects. Object initializers are convenient for setting multiple properties at once without requiring a constructor parameter for each one. For immutable data, init-only setters allow initialization via object initializer syntax while preventing later mutation.
// Constructor — the primary way to create objects
var account = new BankAccount("Alice", "ACC-001", 500m);
// Object initializer — sets properties after the constructor runs
var account2 = new BankAccount("Bob", "ACC-002")
{
OverdraftLimit = 100m,
Owner = "Robert"
};
// init-only setters (C# 9) — settable during initialization, immutable afterwards
public record ProductConfig
{
public required string Name { get; init; } // required — must be provided
public decimal Price { get; init; }
public int Stock { get; init; } = 0; // default if omitted
}
var product = new ProductConfig { Name = "Widget", Price = 9.99m };
// product.Price = 5m; // compile error — init-only after creation
Properties in Depth
Properties are the recommended way to expose class state. They look like fields to callers but execute code under the hood, giving you a place to add validation, transformation, or lazy loading without changing the public API. This is why you should always prefer properties over public fields.
public class Person
{
private string _name = "";
// Full property — getter and setter with custom logic
public string Name
{
get => _name;
set
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Name cannot be blank");
_name = value.Trim(); // normalize whitespace on the way in
}
}
// Auto-property with private setter — readable by anyone, writable only internally
public DateTime BirthDate { get; private set; }
// Computed property — derived from other data, no backing field needed
public int Age => (int)((DateTime.Today - BirthDate).TotalDays / 365.25);
// init-only (C# 9) — set in constructor or object initializer only
public required string Email { get; init; }
}
Inheritance
Inheritance lets a derived class build on an existing class, reusing its code while specializing or extending its behavior. The virtual/override pair is how C# achieves polymorphism — a base class variable can hold any derived type, and the correct method is dispatched at runtime based on the actual object, not the declared variable type.
// Base class — defines the shared structure and default behavior
public class Animal
{
public string Name { get; }
public int Age { get; }
public Animal(string name, int age)
{
Name = name;
Age = age;
}
// virtual — derived classes may override this method
public virtual string Speak() => "...";
public virtual string Describe() =>
$"{GetType().Name} named {Name}, age {Age}";
}
// Derived class — inherits everything from Animal, overrides what it needs
public class Dog : Animal
{
public string Breed { get; }
// : base(...) calls the parent constructor
public Dog(string name, int age, string breed) : base(name, age)
{
Breed = breed;
}
public override string Speak() => "Woof!";
// base.Describe() reuses the parent's implementation, then extends it
public override string Describe() =>
base.Describe() + $", breed: {Breed}";
}
public class Cat : Animal
{
public Cat(string name, int age) : base(name, age) { }
public override string Speak() => "Meow!";
}
// Polymorphism — a List<Animal> holds Dogs and Cats
// The correct Speak() is called based on the actual runtime type
var animals = new List<Animal>
{
new Dog("Rex", 3, "German Shepherd"),
new Cat("Whiskers", 5),
new Dog("Buddy", 2, "Labrador")
};
foreach (var animal in animals)
Console.WriteLine($"{animal.Name}: {animal.Speak()}");
// Rex: Woof!
// Whiskers: Meow!
// Buddy: Woof!
Abstract Classes
Abstract classes sit between concrete classes and interfaces. They can contain both implemented methods (shared, reusable code) and abstract methods (contracts that derived classes must fulfill). Use them when a group of related types share real implementation but each needs to supply its own version of certain operations — for example, all shapes share a Print method but each calculates Area differently.
public abstract class Shape
{
public string Color { get; set; } = "White";
// Abstract — no implementation here; each derived class provides its own
public abstract double Area();
public abstract double Perimeter();
// Concrete — shared by all shapes; no need to override
public void Print()
{
Console.WriteLine($"{GetType().Name}: area={Area():F2}, perimeter={Perimeter():F2}");
}
}
public class Circle : Shape
{
public double Radius { get; }
public Circle(double radius) => Radius = radius;
// Must implement all abstract members
public override double Area() => Math.PI * Radius * Radius;
public override double Perimeter() => 2 * Math.PI * Radius;
}
public class Rectangle : Shape
{
public double Width { get; }
public double Height { get; }
public Rectangle(double w, double h) => (Width, Height) = (w, h);
public override double Area() => Width * Height;
public override double Perimeter() => 2 * (Width + Height);
}
Shape s = new Circle(5);
s.Print(); // Circle: area=78.54, perimeter=31.42
sealed Classes and Methods
sealed is a precision tool for locking down inheritance. Sealing a class prevents any other class from inheriting it, making its behavior completely predictable and enabling JIT optimizations. Sealing an override on a method stops further specialization in subclasses without closing the whole inheritance hierarchy.
// sealed class — no subclasses allowed; good for singletons and value objects
public sealed class Singleton
{
private static readonly Lazy<Singleton> _instance =
new(() => new Singleton());
private Singleton() { } // private constructor prevents external construction
public static Singleton Instance => _instance.Value;
}
// sealed override — GoldenRetriever locks Speak; nothing that inherits from it can change it
public class GoldenRetriever : Dog
{
public GoldenRetriever(string name, int age) : base(name, age, "Golden Retriever") { }
public sealed override string Speak() => "Bark bark!";
}
Static Members
Static members belong to the class itself, not to any instance. They are shared across all instances and persist for the application’s lifetime. Use them for counters, caches, factory methods, and utilities that do not depend on instance state.
public class Counter
{
// Static field — one copy shared across every Counter instance
private static int _totalCount = 0;
private static readonly object _lock = new(); // guards against race conditions
public int Id { get; }
public Counter()
{
lock (_lock)
{
_totalCount++;
Id = _totalCount; // each instance gets a unique sequential ID
}
}
public static int TotalCount => _totalCount;
public static void Reset() { lock (_lock) _totalCount = 0; }
}
var c1 = new Counter(); // Id=1
var c2 = new Counter(); // Id=2
Console.WriteLine(Counter.TotalCount); // 2
Encapsulation Best Practices
Encapsulation means hiding internal representation and only exposing what callers actually need. The payoff is that you can change the internals without breaking callers, and you enforce invariants that keep the object in a valid state. The Order class below is a good example: callers can read lines but cannot add to the internal list directly, so the class always validates before mutating.
public class Order
{
private readonly List<OrderLine> _lines = new();
// Expose a read-only view — callers can iterate but cannot call Add or Remove
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public int LineCount => _lines.Count;
// Computed from internal data — callers get the total without touching _lines
public decimal Total => _lines.Sum(l => l.Total);
// All mutations go through methods — validation and business rules live here
public void AddLine(string product, int qty, decimal unitPrice)
{
if (qty <= 0) throw new ArgumentOutOfRangeException(nameof(qty));
_lines.Add(new OrderLine(product, qty, unitPrice));
}
public bool RemoveLine(string product)
{
var line = _lines.FirstOrDefault(l => l.Product == product);
return line != null && _lines.Remove(line);
}
}