Skip to main content
C# beginner Lesson 11 of 25

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);
    }
}

Frequently Asked Questions

What is the difference between a field and a property?
A field is a raw variable on a class. A property is a field with getter/setter methods that give you control over read and write access. Prefer properties for public API — they let you add validation, lazy loading, or change notification without breaking callers.
When should I use abstract class vs interface?
Use an abstract class when related types share common implementation (you want to reuse code). Use an interface when unrelated types need to fulfill a contract. A class can implement multiple interfaces but only inherit one abstract class.
What does the sealed keyword do?
sealed on a class prevents other classes from inheriting from it. sealed on a method prevents further overriding in derived classes. The JIT can optimize sealed class method calls because it knows no virtual dispatch is needed.