On this page

Classes and objects in C# 14

15 min read TextCh. 2 — OOP in C#

Classes in C#

A class is a blueprint for creating objects. It defines the structure (properties/fields) and the behavior (methods) that instances created from it will have.

// Minimal class definition
public class Person
{
    public string Name { get; set; } = "";
    public int    Age  { get; set; }
}

// Creating instances
var p1 = new Person { Name = "David", Age = 30 };
var p2 = new Person();
p2.Name = "Maria";
p2.Age  = 25;

Access modifiers

Access modifiers control from where a member can be accessed:

Modifier Accessible from
public Anywhere
private Only within the same class
protected Class + derived classes
internal Within the same assembly
protected internal Assembly or derived classes
private protected Same class + derived in same assembly

By default, class members are private and classes are internal.

Properties

Properties in C# encapsulate fields and allow adding validation:

public class BankAccount
{
    // Auto-implemented property — the compiler generates the backing field
    public string Owner { get; set; } = string.Empty;

    // Property with custom validation
    private decimal _balance;
    public decimal Balance
    {
        get => _balance;
        private set   // can only be set from within the class
        {
            if (value < 0)
                throw new InvalidOperationException("Balance cannot be negative");
            _balance = value;
        }
    }

    // Computed property (no backing field)
    public bool HasBalance => _balance > 0;

    // init-only property — assignable only during construction
    public string AccountNumber { get; init; } = Guid.NewGuid().ToString();

    public BankAccount(string owner, decimal initialBalance)
    {
        Owner   = owner;
        Balance = initialBalance;
    }

    public void Deposit(decimal amount)
    {
        if (amount <= 0) throw new ArgumentException("Amount must be positive");
        Balance += amount;
    }

    public void Withdraw(decimal amount)
    {
        if (amount > Balance) throw new InvalidOperationException("Insufficient funds");
        Balance -= amount;
    }
}

Constructors

A constructor initializes the object when it is created:

public class Connection
{
    public string Host   { get; }
    public int    Port   { get; }
    public bool   UseSsl { get; }

    // Primary constructor
    public Connection(string host, int port, bool useSsl = true)
    {
        Host   = host;
        Port   = port;
        UseSsl = useSsl;
    }

    // Convenience constructor — calls the primary with :this()
    public Connection(string host) : this(host, 5432) { }

    // Static constructor — runs once when the type is loaded
    static Connection()
    {
        Console.WriteLine("Connection type initialized");
    }
}

var conn1 = new Connection("localhost", 1433, false);
var conn2 = new Connection("db.example.com"); // uses port 5432 and SSL

Primary constructors (C# 12+)

The most concise syntax for simple classes:

// Without primary constructor (classic C#)
public class ClassicPoint
{
    public double X { get; }
    public double Y { get; }
    public ClassicPoint(double x, double y) { X = x; Y = y; }
}

// With primary constructor (C# 12+)
public class Point(double x, double y)
{
    public double X { get; } = x;
    public double Y { get; } = y;

    // Parameters x and y are available throughout the class body
    public double DistanceFromOrigin() => Math.Sqrt(x * x + y * y);

    public override string ToString() => $"({x:F2}, {y:F2})";
}

var p = new Point(3.0, 4.0);
Console.WriteLine(p);                       // (3.00, 4.00)
Console.WriteLine(p.DistanceFromOrigin());  // 5

Static members

Static members belong to the class, not to instances:

public class Counter
{
    private static int _total = 0;
    public static int Total => _total;

    public int Id { get; }

    public Counter()
    {
        _total++;
        Id = _total;
    }

    // Static factory method
    public static Counter Create() => new Counter();

    // Static utility method
    public static void Reset() => _total = 0;
}

var c1 = Counter.Create();
var c2 = Counter.Create();
var c3 = Counter.Create();
Console.WriteLine(Counter.Total); // 3

Object initializers

The {} syntax to initialize properties without an explicit constructor:

public class PostalAddress
{
    public string Street     { get; set; } = string.Empty;
    public string City       { get; set; } = string.Empty;
    public string Country    { get; set; } = "United States";
    public string PostalCode { get; set; } = string.Empty;
}

// Object initializer
var addr = new PostalAddress
{
    Street     = "123 Main St",
    City       = "New York",
    // Country uses the default "United States"
    PostalCode = "10001"
};

// init-only — assignable only in initializer, immutable after
public class Config
{
    public string ApiUrl  { get; init; } = string.Empty;
    public int    Timeout { get; init; } = 30;
    public bool   Debug   { get; init; }
}

var cfg = new Config { ApiUrl = "https://api.example.com", Timeout = 60 };
// cfg.Timeout = 90; // ← Compile error: init-only property

Partial classes

Allow splitting a class across multiple files:

// File: Order.cs
public partial class Order
{
    public int     Id       { get; set; }
    public decimal Total    { get; set; }
    public string  Customer { get; set; } = string.Empty;
}

// File: Order.Validation.cs
public partial class Order
{
    public bool IsValid() => Total > 0 && !string.IsNullOrEmpty(Customer);
    public void Validate()
    {
        if (!IsValid())
            throw new InvalidOperationException("Invalid order");
    }
}

sealed and abstract classes

// sealed — cannot be inherited
public sealed class Singleton
{
    private static readonly Singleton _instance = new();
    public static Singleton Instance => _instance;
    private Singleton() { }
}

// abstract — cannot be instantiated directly
public abstract class Shape
{
    public abstract double Area();
    public abstract double Perimeter();

    // Concrete method available to subclasses
    public string Describe() => $"Area: {Area():F2}, Perimeter: {Perimeter():F2}";
}

Practice

  1. BankAccount class: Implement a class with validated Balance (non-negative), Owner, and an init-only AccountNumber. Add Deposit and Withdraw methods with proper validation.
  2. Primary constructor: Rewrite Point(double x, double y) with a primary constructor and add a MoveTo(double dx, double dy) method that returns a new Point.
  3. Static member: Add a static counter to your BankAccount class to track how many accounts have been created.

In the next lesson we will look at inheritance, abstract classes, interfaces, and how C# implements polymorphism.

Use init for partial immutability
The init keyword lets you assign a property only during object construction (in the constructor or in an object initializer {}). After construction, the property is read-only, which prevents accidental mutations.
Primary constructors (C# 12+)
Primary constructors declare parameters directly in the class signature. They are ideal for simple DTO or Value Object classes. The parameters are available throughout the class body as captured fields.
Sealed classes for performance
Declare classes as sealed when they are not designed to be inherited. The JIT compiler can optimize virtual method dispatch when it knows there are no subclasses. It also communicates design intent clearly.
csharp
// Full class with all modern C# 14 features
public class Product
{
    // Auto-implemented properties
    public int     Id     { get; init; }   // init: only in constructor/initializer
    public string  Name   { get; set; } = string.Empty;
    public decimal Price  { get; set; }

    // Computed property (no backing field)
    public decimal PriceWithTax => Price * 1.13m;

    // Private field with validated property
    private int _stock;
    public int Stock
    {
        get => _stock;
        set
        {
            if (value < 0) throw new ArgumentException("Stock cannot be negative");
            _stock = value;
        }
    }

    // Static member
    public static int TotalCreated { get; private set; }

    // Constructor
    public Product(int id, string name, decimal price, int stock)
    {
        Id    = id;
        Name  = name;
        Price = price;
        Stock = stock;
        TotalCreated++;
    }

    // Override ToString
    public override string ToString()
        => $"[{Id}] {Name} — ${Price:F2} (stock: {Stock})";
}

// Primary constructor (C# 12+)
public class Category(int id, string name)
{
    public int    Id   { get; } = id;
    public string Name { get; } = name;

    public override string ToString() => $"Category: {Name}";
}

// Usage
var prod = new Product(1, "Laptop Pro", 1299.99m, 10);
Console.WriteLine(prod);
Console.WriteLine($"With tax: ${prod.PriceWithTax:F2}");
Console.WriteLine($"Products created: {Product.TotalCreated}");

var cat = new Category(1, "Electronics");
Console.WriteLine(cat);