Classes

Intro

A class is a reference type that defines a blueprint for objects allocated on the managed heap. Multiple variables can reference the same object, so mutations through one reference are visible through all others — a property that enables shared state but also creates aliasing bugs when callers don't expect it. Classes support single-class inheritance, virtual dispatch, finalizers, and the full range of access modifiers, making them the default choice for services, domain entities, and infrastructure types in C#. The key design decision is knowing when NOT to use a class: value-typed data carriers should be record struct or readonly struct (stack-allocated, no GC pressure), and pure data objects with value equality should be record class (auto-generated Equals/GetHashCode/==).

Deeper Explanation

Instances are heap-allocated and accessed through a reference stored on the stack (or inside another heap object). Assignment copies the reference, not the object:

public class Order
{
    public int Id { get; init; }
    public string Customer { get; set; } = string.Empty;
    public decimal Total { get; set; }
}

var a = new Order { Id = 1, Customer = "Acme", Total = 99.99m };
var b = a;          // b points to the SAME object
b.Total = 0m;
Console.WriteLine(a.Total); // 0 — both references share the object

Class Modifiers

abstract

An abstract class cannot be instantiated directly — it exists only to be inherited. It may contain abstract members (no body, must be overridden) and concrete members (shared implementation).

public abstract class Shape
{
    public string Color { get; set; } = "Black";

    // No body — every derived class MUST implement
    public abstract double Area();

    // Shared implementation — derived classes inherit as-is or override
    public virtual string Describe() => $"{Color} shape with area {Area():F2}";
}

public class Circle : Shape
{
    public double Radius { get; init; }
    public override double Area() => Math.PI * Radius * Radius;
}

// Shape s = new Shape();   // Compile error — cannot instantiate abstract class
Shape s = new Circle { Radius = 5 };

Key rules:

Abstract vs Interface: abstract classes carry state and shared implementation but lock you into single inheritance. Interfaces (especially with default interface methods in C# 8+) provide multiple implementation but cannot hold instance state.

sealed

A sealed class cannot be inherited. The compiler can devirtualize method calls on sealed types, enabling small performance gains.

public sealed class JwtToken
{
    public string Value { get; }
    public DateTime Expiry { get; }

    public JwtToken(string value, DateTime expiry)
    {
        Value = value;
        Expiry = expiry;
    }

    public bool IsExpired => DateTime.UtcNow > Expiry;
}

// class ExtendedToken : JwtToken { }  // Compile error — cannot inherit from sealed

You can also seal individual overrides to stop further overriding down the chain:

public class Base
{
    public virtual void Execute() { }
}

public class Middle : Base
{
    public sealed override void Execute() { /* final implementation */ }
}

// public class Bottom : Middle
// {
//     public override void Execute() { } // Compile error — Execute is sealed in Middle
// }

string is a sealed class in the BCL. All structs are implicitly sealed.

static

A static class cannot be instantiated or inherited. It can only contain static members. The compiler enforces this — you cannot add instance fields, properties, or methods.

public static class MathHelpers
{
    public static double Clamp(double value, double min, double max)
        => Math.Max(min, Math.Min(max, value));

    public static double Lerp(double a, double b, double t)
        => a + (b - a) * Clamp(t, 0, 1);
}

// var h = new MathHelpers();  // Compile error

Key rules:

Gotcha: static classes are singletons by nature. If they hold mutable state (static fields), you get global mutable state — hard to test and prone to race conditions.

partial

The partial keyword splits a class definition across multiple files. The compiler merges them into a single type. Commonly used for separating generated code from hand-written code.

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

// Order.Validation.cs
public partial class Order
{
    public bool IsValid() => Id > 0 && !string.IsNullOrWhiteSpace(Customer);
}

Key rules:

Modifier Compatibility

Modifier combination Allowed?
abstract + sealed No in C# source (static is the IL equivalent)
abstract + static No
sealed + static Redundant — static is already sealed
partial + any modifier Yes
abstract + partial Yes
sealed + partial Yes

Pitfalls

  1. Reference equality surprise== compares references, not content. Two new Order(...) with identical fields are not equal unless you override ==/Equals. Use records or implement IEquatable<T> for value-like equality.

  2. Finalizer abuse — Finalizers delay GC (objects with finalizers are promoted to Gen 1+), are non-deterministic, and run on a single finalizer thread. Prefer IDisposable/IAsyncDisposable with using. Only add a finalizer as a safety net for unmanaged resources.

  3. Static mutable state — Static fields in any class (including non-static classes) are effectively global state. They survive GC, are shared across threads, and make unit testing painful. If you must use them, make them readonly or guard with lock/Interlocked.

  4. Abstract class tight coupling — Deriving from an abstract class couples you to its implementation details (constructor signature, protected fields, method call order). Changes in the base class can break all derived classes (fragile base class problem). Prefer interface contracts when you do not need shared state.

  5. Partial class hidden members — Source generators can add fields, methods, and interface implementations to your partial class that you do not see in your source file. Name collisions produce confusing compiler errors pointing at generated code.

Tradeoffs

Decision Option A Option B When A When B
class vs record class Regular class (manual equality, mutable by default) Record class (value equality, with expressions, immutable by convention) Entities with identity semantics (two Order objects with same data are different if IDs differ), mutable state machines, services DTOs, events, messages, API responses — anywhere value equality is natural and immutability is preferred
abstract class vs interface Abstract class (shared state + implementation, single inheritance) Interface (multiple implementation, no instance state, default methods since C# 8) Need shared fields/constructors, template method pattern, protected state Need multiple implementations per type, or only defining a contract without shared state
sealed vs open Sealed (no inheritance, enables devirtualization) Open (extensible) Leaf types, DTOs, types not designed for extension — sealed is safer default Explicitly designed for inheritance with documented extension points
static class vs singleton Static class (no instance, no DI, no interface) Singleton via DI (services.AddSingleton<T>()) Pure utility functions with no state and no need for testing isolation Needs DI injection, interface-based testing, or configuration-dependent behavior

Decision rule: default to sealed class for new types (prevents accidental inheritance, enables compiler optimizations). Use record class for immutable data carriers. Use abstract class only when you need shared instance state across a type hierarchy — otherwise prefer interfaces.

Questions


Whats next