Classes as blueprints and objects as runtime instances: creation, memory, instance versus static members, identity and lifetime.
A class is a type definition: a blueprint that declares fields (state), methods (behaviour), properties, events and constructors. An object is one instance of that class created at run time with new, which allocates it on the managed heap, runs the constructor, and gives you a reference to it. Each object has its own copy of the instance fields; static members belong to the class itself and are shared by all objects. Two references can point to the same object (identity, ReferenceEquals), or to different objects that hold the same values (equality, which depends on how Equals is defined). An object lives until nothing references it and the garbage collector reclaims it.
A class is a type definition — a blueprint describing data (fields, properties) and behaviour (methods). An object is an instance of that class created at run time, with its own copy of the instance state. Then I cover how new creates it, where it lives, instance vs static members, and identity vs equality.
public sealed class Order(Guid id) // the class: compiled once into metadata + IL
{
private static int s_created; // static: ONE per type
private readonly List<OrderLine> _lines = []; // instance: one per object
public Guid Id { get; } = id;
public Order() : this(Guid.NewGuid()) => Interlocked.Increment(ref s_created);
public static int Created => s_created;
}
var a = new Order(); // object #1
var b = new Order(); // object #2 - same class, separate state
The class exists once (its metadata and a MethodTable in the runtime); you can create any number of objects from it.
new doesnew returns a reference to the object, which you store in a variable.delete.| Instance members | Static members | |
|---|---|---|
| Belong to | Each object | The type itself |
| Accessed via | order.Total |
Order.Created |
Has this |
Yes | No |
| Lifetime | The object's lifetime | The app's lifetime (per type, per closed generic) |
| Typical use | State and behaviour of one thing | Factories, constants, caches, helpers |
Static mutable state is shared by every request and thread — a common source of race conditions and test pollution. In ASP.NET Core, shared state belongs in DI singletons.
ReferenceEquals(a, b). Two new calls always produce different identities.Equals/==. For classes the default is reference equality; types such as string and records override it for value equality.var m1 = new Money(10m, "AUD"); // record
var m2 = new Money(10m, "AUD");
ReferenceEquals(m1, m2); // false - two objects
m1 == m2; // true - same value
Entities (orders, customers) usually have identity defined by an ID; value objects (money, addresses) have value equality.
A caching bug in an API stemmed from treating identity as equality: a Dictionary<Customer, Discount> used Customer objects loaded in different requests as keys. Two objects for the same customer had different identities (no Equals override), so the cache never hit and grew without bound. Keying by CustomerId (a value) fixed both the hit rate and the memory growth.
A fundamentals check that also reveals whether the candidate understands memory and state.
When the compiler builds a class it emits metadata (TypeDef, fields, methods, signatures) and IL. At run time, the first use of the type makes the CLR build:
Every object points to its MethodTable; that is how GetType(), casts, virtual calls and the GC know what the object is.
[ object header (8 B) ][ MethodTable* (8 B) ][ instance fields ... ]
^ sync block index / hash code / thin lock ^ reference points HERE
You can inspect layouts with the ObjectLayoutInspector package or dotnet-dump (dumpobj).
Each thread has an allocation context — a small region of Gen0 memory. Allocating is: check that current + size <= limit, bump current, write the MethodTable pointer. That's a few instructions, which is why allocation in .NET is cheap; the real cost comes later as GC work proportional to surviving objects. When the context is exhausted, the allocator gets a new chunk, and may trigger a Gen0 GC.
Static fields are per closed type: Cache<int>.Items and Cache<string>.Items are separate. Static fields of reference type are roots for the GC — anything referenced from a static lives forever unless removed, which is the classic static-cache memory leak. [ThreadStatic] and ThreadLocal<T> give per-thread statics; AsyncLocal<T> flows values with async context.
ReferenceEquals(a, b) compares the two pointers.RuntimeHelpers.GetHashCode(obj) returns the identity hash (stored lazily in the object header), even if GetHashCode is overridden — used by ReferenceEqualityComparer (.NET 5) for identity-based dictionaries.| Concept | Identity | Equality | C# typical choice |
|---|---|---|---|
| Entity | ID, persists across changes | By ID (or reference within a unit of work) | class |
| Value object | None | All values | record / readonly record struct |
| DTO | None | Rarely compared | record |
| Service | Single instance per lifetime | N/A | class registered in DI |
ReferenceEquals, Equals and ==.RuntimeHelpers.GetHashCode for both objects.ObjectLayoutInspector to print the layout of a class with mixed field types.public class BankAccount
{
private static int _created; // shared by all accounts
public static decimal InterestRate { get; } = 0.05m;
private decimal _balance; // one per object
public BankAccount(string owner)
{
Owner = owner;
Id = ++_created;
}
public int Id { get; }
public string Owner { get; }
public decimal Balance => _balance;
public void Deposit(decimal amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
_balance += amount; // changes only this object
}
public static int CreatedCount => _created; // no 'this' in static members
}
var alice = new BankAccount("Alice");
var bob = new BankAccount("Bob");
alice.Deposit(100m);
Console.WriteLine(alice.Balance); // 100
Console.WriteLine(bob.Balance); // 0
Console.WriteLine(BankAccount.CreatedCount); // 2
var sameAsAlice = alice;
sameAsAlice.Deposit(50m);
Console.WriteLine(alice.Balance); // 150: same object
Console.WriteLine(ReferenceEquals(alice, sameAsAlice)); // true
What happens: candidates use the terms interchangeably.
Why it is wrong: the class is the definition; objects are runtime instances with their own state.
Do instead: describe the class as a type/blueprint and objects as instances created with new.
What happens: var copy = order; copy.Status = Cancelled; expecting order unchanged.
Why it is wrong: both variables reference the same object.
Do instead: clone explicitly or use records with with for non-destructive changes.
What happens: comparing objects with == expecting value comparison, or using entity objects as dictionary keys.
Why it is wrong: default class equality is reference equality.
Do instead: compare IDs for entities, use records for values, or implement equality deliberately.
What happens: static List<Order> Pending shared across requests.
Why it is wrong: race conditions, cross-tenant data leaks, and tests that interfere with each other.
Do instead: use DI singletons with thread-safe structures, or scoped services.
What happens: static dictionaries keep adding entries.
Why it is wrong: statics are GC roots — memory grows until the process is killed.
Do instead: use IMemoryCache with size limits and expiry.
What happens: expecting a file or connection to close when the variable goes out of scope.
Why it is wrong: the GC reclaims memory later, non-deterministically; unmanaged resources need Dispose.
Do instead: use using/await using for disposable objects.
What happens: business logic in static classes with parameters passed everywhere. Why it is wrong: hard to substitute in tests and hides dependencies. Do instead: use instance classes with injected dependencies for behaviour that depends on configuration or I/O.
What happens: oversimplified memory explanations. Why it is wrong: structs live inline in their container (often the heap), and the JIT can stack-allocate non-escaping objects. Do instead: explain semantics first (reference vs value), then location as an implementation detail.
Seniors use the class/object distinction to reason about state, sharing and lifetime, which is where most real bugs come from.
| Question | Why it matters |
|---|---|
| Where does this state live: object, static, or outside? | shared state causes data leaks and races |
| Who holds references to this object? | lifetime, memory leaks, unintended mutation |
| Identity or value semantics? | equality, dictionary keys, caching |
| Is the object always valid after construction? | invariants, fewer defensive checks |
| Does it own resources? | needs IDisposable |
They also connect this to dependency injection lifetimes: a singleton service is effectively one shared object, so it must not hold per-request state.
A constructor runs when an object is created and should leave it valid. If you declare none, the compiler adds a public parameterless one. Constructors can chain: : this(...) calls another constructor of the same class, : base(...) calls the base class's. For new Derived(), the order is: derived field initialisers, base field initialisers, base constructor body, derived constructor body. A static constructor runs once, before the type is first used, and is thread-safe. Primary constructors (C# 12) put parameters on the class declaration; they are captured as hidden fields, not properties, and are mutable. required members (C# 11) force callers to set them in an object initialiser. Calling a virtual method from a constructor runs the derived override before the derived constructor body has run, so it can see uninitialised state.
A value type holds its data directly, so assigning or passing it copies the data; a reference type variable holds a reference to an object on the managed heap, so copying the variable makes two references to the same object. Value types are stored inline where they are declared: a local usually sits on the stack or in a register, but a struct field of a class lives inside that object on the heap, and captured or boxed values also end up on the heap. By default classes compare by identity and structs by their fields. I use structs for small, immutable values like Money or coordinates, and classes for everything with identity or behaviour.
System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.
Last updated 30 Oct 2026.