OOP in C#

Classes, records for immutable data, interfaces, inheritance, and properties with get/set and auto-properties.

Classes and constructors

C#
public class Car
{
    private string model;
    private int speed;

    public Car(string model)
    {
        this.model = model;
        this.speed = 0;
    }

    public void Accelerate(int amount)
    {
        speed += amount;
    }

    public string Describe()
    {
        return $"{model} is going {speed} km/h";
    }
}

var car = new Car("Civic");
car.Accelerate(40);
Console.WriteLine(car.Describe());   // Civic is going 40 km/h

Properties: get/set and auto-properties

C# properties look like fields from the outside but let you control read/write access with get/set accessors — the idiomatic replacement for manually writing GetX()/SetX() methods:

C#
public class BankAccount
{
    private decimal balance;

    public decimal Balance
    {
        get { return balance; }
        set
        {
            if (value < 0) throw new ArgumentException("Balance cannot be negative");
            balance = value;
        }
    }
}

When there's no extra logic, an auto-property generates the backing field for you:

C#
public class Product
{
    public string Name { get; set; }
    public decimal Price { get; set; }

    // init-only setter (C# 9+) — settable only during object initialization, then immutable
    public string Sku { get; init; }
}

var product = new Product { Name = "Keyboard", Price = 49.99m, Sku = "KB-100" };
// product.Sku = "KB-200";   // Error: init-only property, can't be set after construction

Records — immutable data made easy

A record (C# 9+) is designed for immutable data and gives you value-based equality, a readable ToString(), and a concise with expression for creating modified copies — all generated automatically, none of which a regular class gives you for free:

C#
public record Point(int X, int Y);

var p1 = new Point(3, 4);
var p2 = new Point(3, 4);

Console.WriteLine(p1 == p2);      // True — records compare by value, not reference
Console.WriteLine(p1);            // Point { X = 3, Y = 4 } — auto-generated ToString()

var p3 = p1 with { Y = 10 };      // creates a new record, copying p1 but overriding Y
Console.WriteLine(p3);            // Point { X = 3, Y = 10 }
Console.WriteLine(p1);            // Point { X = 3, Y = 4 } — p1 itself is unchanged

Compare this to a regular class, where == compares references by default, and you'd have to hand-write Equals, GetHashCode, and ToString() to get the same behavior:

C#
public class ClassPoint
{
    public int X { get; set; }
    public int Y { get; set; }
}

var cp1 = new ClassPoint { X = 3, Y = 4 };
var cp2 = new ClassPoint { X = 3, Y = 4 };
Console.WriteLine(cp1 == cp2);   // False — reference equality, different objects

Interfaces

C#
public interface IPayable
{
    decimal CalculatePay();
}

public class Employee : IPayable
{
    public decimal HoursWorked { get; init; }
    public decimal HourlyRate { get; init; }

    public decimal CalculatePay() => HoursWorked * HourlyRate;
}

IPayable employee = new Employee { HoursWorked = 40, HourlyRate = 25m };
Console.WriteLine(employee.CalculatePay());   // 1000

By convention, interface names in C# start with I (IPayable, IDisposable, IEnumerable). A class can implement any number of interfaces, but inherit from only one base class.

Inheritance

C#
public class Vehicle
{
    public int Speed { get; protected set; }

    public virtual void Accelerate()
    {
        Speed += 10;
    }
}

public class SportsCar : Vehicle
{
    public override void Accelerate()
    {
        Speed += 30;   // sports cars accelerate faster
    }
}

Vehicle v = new SportsCar();
v.Accelerate();
Console.WriteLine(v.Speed);   // 30 — the overridden method ran (polymorphism)

A method must be marked virtual in the base class before a subclass can override it — unlike Java, where every non-final method is overridable by default. This is a deliberate C# design choice: overridability must be explicitly opted into.

Common mistakes

  • Using a regular class with hand-rolled Equals/GetHashCode when a record would give you correct value equality for free — reach for record by default for immutable data models (DTOs, value objects).
  • Forgetting virtual on a base class method, then being confused why override in the subclass produces a compiler error.
  • Confusing init (settable only at construction, then locked) with readonly (settable in the constructor body, then locked) — both produce immutability, but init also works cleanly with object-initializer syntax (new Product { Sku = "..." }).

Interview questions

Q: What's the practical difference between a record and a class? A record gets value-based equality (== and .Equals() compare property values, not references), an auto-generated readable ToString(), and support for non-destructive with copies — all generated by the compiler. A class defaults to reference equality and requires you to hand-write all of that yourself if you want the same behavior. Records are the idiomatic choice for immutable data models; classes remain the default for objects with identity and mutable behavior.

Q: Why does C# require the virtual keyword before a method can be overridden? It's a deliberate design decision for clarity and performance — a method is non-overridable (and can be safely inlined/devirtualized by the JIT) unless the author explicitly opts in with virtual, forcing every override relationship in a codebase to be intentional and visible at the base class, rather than implicit like in some other object-oriented languages.