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.
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.
// 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
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.50Properties
Properties are the preferred way to expose data from a class. They look like fields from the outside, but let you add logic inside.
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:
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
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)); // 35Inheritance
Inheritance allows a class to derive from a base class, inheriting its members and specializing behaviour.
// 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:
// 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 classRecords
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() withexpressions for non-destructive mutation
Positional Records
// 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=2Record with Additional Members
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.99record class vs record struct
// 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.
// 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.
// 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:
// 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
// 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
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
// 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:
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
initfor immutable-after-construction properties. - Expression-body members (
=>) keep simple methods and properties concise. virtual/overrideenables polymorphism;sealedprevents further extension.- Records are ideal for data transfer objects, domain value objects, and any type where value equality matters.
withexpressions 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.