Back to blog
Backend Systemsbeginner

C# OOP and Records: Classes, Inheritance, and Modern Data Types

Master C# object-oriented programming: classes, constructors, properties, inheritance, records, interfaces, and structs — with a complete product catalog model.

Asma HafeezApril 17, 202615 min read
csharpooprecordsclassesinterfacesdotnet
Share:𝕏

C# OOP and Records: Classes, Inheritance, and Modern Data Types

Object-Oriented Programming is one of C#'s strengths. Beyond the standard class system, modern C# (9+) adds records — a concise way to define immutable data types with value equality built in. This article covers everything from basic classes through to a full product catalog model.


Classes and Constructors

A class is the blueprint for an object. It bundles data (fields/properties) and behaviour (methods) together.

C#
// A simple class
public class Person
{
    // Fields (private by convention — expose through properties)
    private string _name;
    private int _age;

    // Constructor — called when you do new Person(...)
    public Person(string name, int age)
    {
        _name = name;
        _age = age;
    }

    // Methods
    public string Greet()
    {
        return $"Hi, I'm {_name} and I'm {_age} years old.";
    }

    public void HaveBirthday()
    {
        _age++;
        Console.WriteLine($"Happy birthday {_name}! Now {_age}.");
    }
}

// Usage
var person = new Person("Asma", 30);
Console.WriteLine(person.Greet());
person.HaveBirthday();

Multiple Constructors and Constructor Chaining

C#
public class Product
{
    private string _name;
    private decimal _price;
    private string _category;

    // Primary constructor
    public Product(string name, decimal price, string category)
    {
        _name = name;
        _price = price;
        _category = category;
    }

    // Overloaded constructor — delegates to primary via : this(...)
    public Product(string name, decimal price) : this(name, price, "General")
    {
    }

    // Default constructor
    public Product() : this("Unknown", 0m, "General")
    {
    }

    public override string ToString() => $"{_name} ({_category}) — ${_price:F2}";
}

var p1 = new Product("Laptop", 999.99m, "Electronics");
var p2 = new Product("Pen", 1.50m);        // category defaults to "General"
var p3 = new Product();                     // all defaults
Console.WriteLine(p1);  // Laptop (Electronics) — $999.99
Console.WriteLine(p2);  // Pen (General) — $1.50

Properties

Properties are the preferred way to expose data from a class. They look like fields from the outside, but let you add logic inside.

C#
public class BankAccount
{
    private decimal _balance;

    // Full property with backing field
    public decimal Balance
    {
        get { return _balance; }
        private set
        {
            if (value < 0)
                throw new ArgumentException("Balance cannot be negative.");
            _balance = value;
        }
    }

    // Auto-property — compiler generates the backing field
    public string Owner { get; set; }

    // Init-only property (C# 9) — can only be set during object initialization
    public string AccountNumber { get; init; }

    // Read-only auto-property — set only in constructor
    public DateTime CreatedAt { get; }

    // Computed property — no backing field, calculated on demand
    public bool IsOverdrawn => _balance < 0;
    public string Summary => $"Account {AccountNumber}: ${_balance:F2} (owner: {Owner})";

    public BankAccount(string owner, string accountNumber, decimal initialBalance)
    {
        Owner = owner;
        AccountNumber = accountNumber;
        _balance = initialBalance;
        CreatedAt = DateTime.UtcNow;
    }

    public void Deposit(decimal amount) => Balance += amount;
}

// Object initializer syntax (works with settable properties)
var account = new BankAccount("Asma", "ACC-001", 1000m)
{
    Owner = "Asma Hafeez"  // can reassign set properties
};

// Init-only: can set during initialization...
var account2 = new BankAccount("Bob", "ACC-002", 500m)
{
    // AccountNumber = "NEW";  // ERROR — AccountNumber is init-only
};

Console.WriteLine(account.Summary);

Expression-Body Members

Expression-body syntax (=>) shortens simple members:

C#
public class Circle
{
    public double Radius { get; init; }

    // Expression-body constructor (rare but valid)
    public Circle(double radius) => Radius = radius;

    // Expression-body property
    public double Area => Math.PI * Radius * Radius;
    public double Circumference => 2 * Math.PI * Radius;

    // Expression-body method
    public double ScaledArea(double factor) => Area * factor * factor;

    // Expression-body override
    public override string ToString() => $"Circle(r={Radius:F2}, area={Area:F2})";
}

Methods and Static Members

C#
public class MathHelper
{
    // Static field (shared by all instances — actually no instance needed here)
    public static readonly double GoldenRatio = 1.6180339887;

    // Static method — called on the class, not an instance
    public static int Clamp(int value, int min, int max)
    {
        if (value < min) return min;
        if (value > max) return max;
        return value;
    }

    // Static method with expression body
    public static double Square(double x) => x * x;

    // Instance method (needs an object)
    private int _multiplier;
    public MathHelper(int multiplier) => _multiplier = multiplier;
    public int Multiply(int x) => x * _multiplier;
}

// Usage
Console.WriteLine(MathHelper.GoldenRatio);       // 1.6180339887
Console.WriteLine(MathHelper.Clamp(150, 0, 100)); // 100
Console.WriteLine(MathHelper.Square(4));           // 16

var helper = new MathHelper(5);
Console.WriteLine(helper.Multiply(7));  // 35

Inheritance

Inheritance allows a class to derive from a base class, inheriting its members and specializing behaviour.

C#
// Base class
public class Animal
{
    public string Name { get; init; }
    public int Age { get; init; }

    public Animal(string name, int age)
    {
        Name = name;
        Age = age;
    }

    // virtual: can be overridden in derived classes
    public virtual string MakeSound() => "...";

    public virtual string Describe() => $"{Name} (age {Age})";

    public override string ToString() => Describe();
}

// Derived class
public class Dog : Animal
{
    public string Breed { get; init; }

    public Dog(string name, int age, string breed) : base(name, age)
    {
        Breed = breed;
    }

    // override: replaces the base implementation
    public override string MakeSound() => "Woof!";

    public override string Describe() => $"{base.Describe()}, Breed: {Breed}";

    public void Fetch() => Console.WriteLine($"{Name} fetches the ball!");
}

public class Cat : Animal
{
    public bool IsIndoor { get; init; }

    public Cat(string name, int age, bool isIndoor) : base(name, age)
    {
        IsIndoor = isIndoor;
    }

    public override string MakeSound() => "Meow!";
    public override string Describe() =>
        $"{base.Describe()}, {(IsIndoor ? "indoor" : "outdoor")}";
}

// Polymorphism
Animal[] animals = {
    new Dog("Rex", 3, "Labrador"),
    new Cat("Whiskers", 5, true),
    new Dog("Buddy", 2, "Golden Retriever")
};

foreach (Animal animal in animals)
{
    Console.WriteLine($"{animal.Describe()} says: {animal.MakeSound()}");
}
// Rex (age 3), Breed: Labrador says: Woof!
// Whiskers (age 5), indoor says: Meow!
// Buddy (age 2), Breed: Golden Retriever says: Woof!

Sealed Classes

sealed prevents further inheritance:

C#
// Cannot inherit from a sealed class
public sealed class Singleton
{
    private static Singleton? _instance;
    private static readonly object _lock = new object();

    private Singleton() { }

    public static Singleton Instance
    {
        get
        {
            lock (_lock)
            {
                _instance ??= new Singleton();
                return _instance;
            }
        }
    }

    public void DoWork() => Console.WriteLine("Singleton working...");
}

// public class BadIdea : Singleton { }  // ERROR: cannot inherit from sealed class

Records

Records (C# 9+) are a concise way to define data-centric types. They have:

  • Immutable properties by default (with positional syntax)
  • Value-based equality (== compares property values, not references)
  • Auto-generated ToString()
  • with expressions for non-destructive mutation

Positional Records

C#
// Positional record — everything in one line!
public record Point(double X, double Y);

var p1 = new Point(1.0, 2.0);
var p2 = new Point(1.0, 2.0);
var p3 = new Point(3.0, 4.0);

Console.WriteLine(p1);         // Point { X = 1, Y = 2 }
Console.WriteLine(p1 == p2);   // true  — value equality!
Console.WriteLine(p1 == p3);   // false

// with expression — create a modified copy
var p4 = p1 with { X = 10.0 };
Console.WriteLine(p4);         // Point { X = 10, Y = 2 }
Console.WriteLine(p1);         // Point { X = 1, Y = 2 } — unchanged!

// Deconstruction (auto-generated for positional records)
var (x, y) = p1;
Console.WriteLine($"x={x}, y={y}");  // x=1, y=2

Record with Additional Members

C#
public record Product(
    Guid Id,
    string Name,
    decimal Price,
    string Category,
    int StockQuantity = 0
)
{
    // Additional computed property
    public bool IsInStock => StockQuantity > 0;

    // Additional method
    public Product ApplyDiscount(decimal percentage)
    {
        if (percentage is < 0 or > 100)
            throw new ArgumentOutOfRangeException(nameof(percentage));

        decimal discountedPrice = Price * (1 - percentage / 100);
        return this with { Price = discountedPrice };
    }

    // Custom validation in constructor
    public Product
    {
        if (string.IsNullOrWhiteSpace(Name))
            throw new ArgumentException("Name cannot be empty.", nameof(Name));
        if (Price < 0)
            throw new ArgumentException("Price cannot be negative.", nameof(Price));
    }
}

var laptop = new Product(Guid.NewGuid(), "Laptop Pro", 1299.99m, "Electronics", 50);
Console.WriteLine(laptop);
// Product { Id = ..., Name = Laptop Pro, Price = 1299.99, Category = Electronics, StockQuantity = 50 }

var discounted = laptop.ApplyDiscount(10);
Console.WriteLine($"Original: {laptop.Price:C}");    // $1,299.99
Console.WriteLine($"Discounted: {discounted.Price:C}"); // $1,169.99

record class vs record struct

C#
// record class — reference type, on the heap
public record class PersonRecord(string FirstName, string LastName);

// record struct — value type, on the stack (C# 10+)
public record struct Coordinate(double Lat, double Lon);

// readonly record struct — all properties init-only
public readonly record struct Money(decimal Amount, string Currency);

var coord1 = new Coordinate(51.5, -0.1);
var coord2 = coord1 with { Lat = 52.0 };
Console.WriteLine(coord1);  // Coordinate { Lat = 51.5, Lon = -0.1 }
Console.WriteLine(coord2);  // Coordinate { Lat = 52, Lon = -0.1 }

// record struct value equality
var m1 = new Money(100m, "USD");
var m2 = new Money(100m, "USD");
Console.WriteLine(m1 == m2);  // true (value equality)

Interfaces

Interfaces define a contract — a set of members that implementing classes must provide.

C#
// Define interface
public interface IPriceable
{
    decimal Price { get; }
    string Currency { get; }
    decimal GetPriceInCurrency(string targetCurrency);
}

public interface IDiscountable
{
    decimal ApplyDiscount(decimal percentage);
}

// Default interface method (C# 8+)
public interface IDescribable
{
    string Name { get; }

    // Default implementation — classes don't HAVE to override this
    string GetDescription() => $"Item: {Name}";
}

// Implement multiple interfaces
public class CatalogItem : IPriceable, IDiscountable, IDescribable
{
    public string Name { get; init; }
    public decimal Price { get; private set; }
    public string Currency { get; init; } = "USD";

    public CatalogItem(string name, decimal price)
    {
        Name = name;
        Price = price;
    }

    public decimal GetPriceInCurrency(string targetCurrency)
    {
        // Simplified conversion — real apps use an exchange rate service
        return targetCurrency switch
        {
            "EUR" => Price * 0.92m,
            "GBP" => Price * 0.79m,
            "USD" => Price,
            _ => throw new NotSupportedException($"Currency {targetCurrency} not supported")
        };
    }

    public decimal ApplyDiscount(decimal percentage)
    {
        Price *= (1 - percentage / 100);
        return Price;
    }

    // Override default interface method (optional)
    public string GetDescription() => $"[{Currency}] {Name}: {Price:C}";
}

// Program against the interface, not the concrete type
IPriceable item = new CatalogItem("Wireless Mouse", 49.99m);
Console.WriteLine(item.GetPriceInCurrency("EUR"));  // 45.99..

IDiscountable disc = (IDiscountable)item;
disc.ApplyDiscount(10);
Console.WriteLine(item.Price);  // 44.99...

Structs vs Classes

Structs are value types — they live on the stack (usually), are copied on assignment, and cannot inherit from other structs or classes.

C#
// Good use case for struct: small, immutable, frequently created value
public struct Temperature
{
    public double Celsius { get; init; }
    public double Fahrenheit => Celsius * 9 / 5 + 32;
    public double Kelvin => Celsius + 273.15;

    public Temperature(double celsius) => Celsius = celsius;

    public static Temperature FromFahrenheit(double f) =>
        new Temperature((f - 32) * 5 / 9);

    public override string ToString() =>
        $"{Celsius:F1}°C / {Fahrenheit:F1}°F / {Kelvin:F1}K";
}

var boiling = new Temperature(100);
var body = Temperature.FromFahrenheit(98.6);
Console.WriteLine(boiling);  // 100.0°C / 212.0°F / 373.2K
Console.WriteLine(body);     // 37.0°C / 98.6°F / 310.2K

// Struct copy behaviour
var t1 = new Temperature(25);
var t2 = t1;  // COPY — t2 is independent
// t2.Celsius = 30;  // Would not affect t1 (if Celsius had a setter)

When to use struct vs class:

| Use struct when | Use class when | |---|---| | Small (< 16 bytes) | Larger objects | | Immutable value | Mutable state | | Frequently allocated | Infrequent allocation | | Value semantics needed | Reference semantics needed | | e.g., Point, Color, Temperature | e.g., Customer, Order, Service |


Project: Product Catalog Model

Here is a complete product catalog system using all the concepts above:

C#
// Models/Category.cs
public record Category(string Id, string Name, string Description)
{
    public static readonly Category Electronics = new("electronics", "Electronics", "Gadgets and devices");
    public static readonly Category Books = new("books", "Books", "Physical and digital books");
    public static readonly Category Clothing = new("clothing", "Clothing", "Apparel and accessories");
}

// Models/Money.cs
public readonly record struct Money(decimal Amount, string Currency = "USD")
{
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException($"Cannot add {Currency} and {other.Currency}");
        return this with { Amount = Amount + other.Amount };
    }

    public Money Multiply(decimal factor) => this with { Amount = Amount * factor };

    public override string ToString() => $"{Amount:F2} {Currency}";
}

// Models/Product.cs
public record Product(
    string Id,
    string Name,
    string Description,
    Money Price,
    Category Category,
    int StockQuantity,
    IReadOnlyList<string> Tags
)
{
    // Computed properties
    public bool IsAvailable => StockQuantity > 0;
    public bool IsLowStock => StockQuantity is > 0 and < 5;

    // Custom validation
    public Product
    {
        if (string.IsNullOrWhiteSpace(Id))
            throw new ArgumentException("Id required", nameof(Id));
        if (string.IsNullOrWhiteSpace(Name))
            throw new ArgumentException("Name required", nameof(Name));
        if (Price.Amount < 0)
            throw new ArgumentException("Price cannot be negative", nameof(Price));
        if (StockQuantity < 0)
            throw new ArgumentException("Stock cannot be negative", nameof(StockQuantity));
    }

    public Product WithDiscount(decimal percentage) =>
        this with { Price = Price.Multiply(1 - percentage / 100) };

    public Product Restock(int quantity) =>
        this with { StockQuantity = StockQuantity + quantity };
}

// Services/ProductCatalog.cs
public class ProductCatalog
{
    private readonly List<Product> _products = new();

    public void Add(Product product)
    {
        if (_products.Any(p => p.Id == product.Id))
            throw new InvalidOperationException($"Product {product.Id} already exists.");
        _products.Add(product);
    }

    public Product? FindById(string id) =>
        _products.FirstOrDefault(p => p.Id == id);

    public IReadOnlyList<Product> FindByCategory(Category category) =>
        _products.Where(p => p.Category == category).ToList();

    public IReadOnlyList<Product> FindAvailable() =>
        _products.Where(p => p.IsAvailable).ToList();

    public IReadOnlyList<Product> FindLowStock() =>
        _products.Where(p => p.IsLowStock).ToList();

    public IReadOnlyList<Product> Search(string query) =>
        _products
            .Where(p => p.Name.Contains(query, StringComparison.OrdinalIgnoreCase)
                     || p.Description.Contains(query, StringComparison.OrdinalIgnoreCase)
                     || p.Tags.Any(t => t.Contains(query, StringComparison.OrdinalIgnoreCase)))
            .ToList();

    public void ApplyCategoryDiscount(Category category, decimal discountPercentage)
    {
        var affected = _products.Where(p => p.Category == category).ToList();
        foreach (var product in affected)
        {
            var index = _products.IndexOf(product);
            _products[index] = product.WithDiscount(discountPercentage);
        }
        Console.WriteLine($"Applied {discountPercentage}% discount to {affected.Count} products in {category.Name}.");
    }

    public CatalogReport GenerateReport()
    {
        return new CatalogReport(
            TotalProducts: _products.Count,
            TotalAvailable: _products.Count(p => p.IsAvailable),
            TotalLowStock: _products.Count(p => p.IsLowStock),
            TotalOutOfStock: _products.Count(p => !p.IsAvailable),
            MostExpensive: _products.MaxBy(p => p.Price.Amount),
            LeastExpensive: _products.Where(p => p.IsAvailable).MinBy(p => p.Price.Amount),
            Categories: _products.GroupBy(p => p.Category.Name)
                .ToDictionary(g => g.Key, g => g.Count())
        );
    }

    public IReadOnlyList<Product> All => _products.AsReadOnly();
}

// Models/CatalogReport.cs
public record CatalogReport(
    int TotalProducts,
    int TotalAvailable,
    int TotalLowStock,
    int TotalOutOfStock,
    Product? MostExpensive,
    Product? LeastExpensive,
    Dictionary<string, int> Categories
)
{
    public void Print()
    {
        Console.WriteLine("=== Catalog Report ===");
        Console.WriteLine($"Total Products:    {TotalProducts}");
        Console.WriteLine($"Available:         {TotalAvailable}");
        Console.WriteLine($"Low Stock:         {TotalLowStock}");
        Console.WriteLine($"Out of Stock:      {TotalOutOfStock}");
        Console.WriteLine($"Most Expensive:    {MostExpensive?.Name} ({MostExpensive?.Price})");
        Console.WriteLine($"Least Expensive:   {LeastExpensive?.Name} ({LeastExpensive?.Price})");
        Console.WriteLine("\nBy Category:");
        foreach (var (cat, count) in Categories)
            Console.WriteLine($"  {cat}: {count} products");
    }
}

Wiring It All Together

C#
// Program.cs
var catalog = new ProductCatalog();

// Seed data
catalog.Add(new Product("laptop-001", "ThinkPad X1",
    "Business ultrabook with 16GB RAM",
    new Money(1299.99m), Category.Electronics, 15,
    new[] { "laptop", "business", "ultrabook" }));

catalog.Add(new Product("mouse-001", "Wireless Mouse",
    "Ergonomic 3-button wireless mouse",
    new Money(39.99m), Category.Electronics, 3,
    new[] { "mouse", "wireless", "ergonomic" }));

catalog.Add(new Product("book-001", "Clean Code",
    "A handbook of agile software craftsmanship",
    new Money(45.00m), Category.Books, 0,
    new[] { "programming", "software", "agile" }));

catalog.Add(new Product("book-002", "The Pragmatic Programmer",
    "From journeyman to master",
    new Money(52.00m), Category.Books, 8,
    new[] { "programming", "career", "best-practices" }));

catalog.Add(new Product("shirt-001", "Dev Hoodie",
    "Comfortable cotton programmer hoodie",
    new Money(59.99m), Category.Clothing, 4,
    new[] { "clothing", "hoodie", "developer" }));

// Demonstrate features
Console.WriteLine("=== Available Products ===");
foreach (var p in catalog.FindAvailable())
    Console.WriteLine($"  {p.Name}: {p.Price} (stock: {p.StockQuantity}{(p.IsLowStock ? "  LOW" : "")})");

Console.WriteLine("\n=== Search: 'programming' ===");
foreach (var p in catalog.Search("programming"))
    Console.WriteLine($"  {p.Name}");

Console.WriteLine("\n=== Apply 20% Electronics Discount ===");
catalog.ApplyCategoryDiscount(Category.Electronics, 20);

var laptop = catalog.FindById("laptop-001");
Console.WriteLine($"Laptop new price: {laptop?.Price}");

catalog.GenerateReport().Print();

Common Mistakes

1. Mutating Records After Creation

C#
public record Order(string Id, decimal Total);

var order = new Order("ORD-001", 99.99m);
// order.Total = 149.99m;  // ERROR — records are init-only by default

// Correct: create a new record with the updated value
var updatedOrder = order with { Total = 149.99m };

2. Class vs Record for Mutable Services

C#
// WRONG: using a record for a service (records are for data, not behaviour)
public record UserService(IUserRepository Repo)
{
    public void CreateUser(string name) { /* ... */ }
}

// CORRECT: class for services with behaviour
public class UserService
{
    private readonly IUserRepository _repo;
    public UserService(IUserRepository repo) => _repo = repo;
    public void CreateUser(string name) { /* ... */ }
}

3. Forgetting to Override ToString on Classes

Records auto-generate ToString(). Classes do not — the default just prints the type name:

C#
public class Widget { public string Name { get; init; } = ""; }
var w = new Widget { Name = "Gear" };
Console.WriteLine(w);  // "Widget" — NOT useful

// Add this:
public override string ToString() => $"Widget({Name})";

Key Takeaways

  • Properties expose data with controlled access; use init for immutable-after-construction properties.
  • Expression-body members (=>) keep simple methods and properties concise.
  • virtual/override enables polymorphism; sealed prevents further extension.
  • Records are ideal for data transfer objects, domain value objects, and any type where value equality matters.
  • with expressions allow non-destructive mutation — create a copy with one field changed.
  • Interfaces define contracts; a class can implement many interfaces but only inherit one class.
  • Structs are value types — great for small, immutable data; avoid mutable structs.

What's Next

  • C# LINQ — query your product catalog with expressive data operations
  • C# Async/Await — load your catalog from a database asynchronously
  • C# Bank Project — a complete end-to-end application

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.