Model data with C# classes: properties, constructors and primary constructors, static members, records, structs, equality and nullable reference types.
Write a class with private fields, validated properties, constructors and methods, and create objects from it
Use init-only and required properties, primary constructors and object initializers to build objects safely
Choose between a class, a record and a struct from how each one copies and compares
Override ToString, Equals and GetHashCode correctly, and know when a record does it for you
Read nullable reference type warnings and prevent a NullReferenceException before it happens
01
Classes, objects and this
✓
A class is a blueprint: it declares what data an object holds (its fields) and what it can do (its methods). An object (or instance) is one thing built from that blueprint with new. Two objects of the same class have their own copies of every field, so changing one account's balance leaves the other alone.
Inside an instance method, this means "the object this method was called on". You mostly leave it out, since balance already means this.balance, but you need it when a parameter has the same name as a field, or when you pass the current object to another method.
C#Program.cs
123456789101112131415161718192021222324252627
var ana =newBankAccount("Ana");var ben =newBankAccount("Ben");
ana.Deposit(100);
ana.Deposit(50);
ben.Deposit(20);Console.WriteLine(ana.Describe());Console.WriteLine(ben.Describe());classBankAccount{privatestring owner;// fields: each object has its ownprivatedecimal balance;publicBankAccount(string owner){this.owner = owner;// this.owner is the field, owner the parameter}publicvoidDeposit(decimal amount){
balance += amount;// same as this.balance += amount}publicstringDescribe()=> $"{owner}: {balance:0.00}";}
Outputcompiled & run with real C#
Ana: 150.00
Ben: 20.00
Your turn
Add a Withdraw(decimal amount) method that refuses (prints a message and changes nothing) when the balance would go below zero.
Where objects live
A class is a reference type. new BankAccount("Ana") allocates the object on the heap and gives you a reference to it; var ana holds that reference. Copying ana into another variable copies the reference, not the object. The garbage collector frees the object once nothing refers to it (Module 00).
02
Properties, access modifiers and encapsulation
✓
Encapsulation means an object guards its own data: fields stay private, and the outside world goes through members that can check what it asks for. In C# those members are properties: they look like fields at the call site (account.Balance) but run code in a get and set accessor.
Modifier
Visible to
public
Any code
private
Only code inside the same type (the default for members)
protected
The type and classes derived from it (Module 06)
internal
Any code in the same assembly (project); the default for top-level types
protected internal / private protected
Combinations of the two, used mostly in libraries
C#Program.cs
123456789101112131415161718192021222324252627
var t =newThermostat();
t.Celsius =21.5;Console.WriteLine($"{t.Celsius} C = {t.Fahrenheit} F");
t.Celsius =-500;// rejected by the setterConsole.WriteLine($"still {t.Celsius} C");Console.WriteLine($"changed {t.Changes} times");classThermostat{privatedouble celsius =20;// backing fieldpublicdoubleCelsius{get=> celsius;set{if(value <-273.15){Console.WriteLine($"ignored {value}");return;}
celsius = value;Changes++;}}publicdoubleFahrenheit=>Celsius*9/5+32;// computed, read-onlypublicintChanges{get;privateset;}// auto-property, private setter}
Outputcompiled & run with real C#
21.5 C = 70.7 F
ignored -500
still 21.5 C
changed 1 times
value is the implicit parameter of every set accessor. Changes is an auto-property: the compiler writes the hidden field for you.
Error you will hit
CS0122: reaching a private member from outside
C#
12345678
var acct =newAccount();
acct.balance =1_000_000;classAccount{privatedecimal balance;publicdecimalBalance=> balance;}
Program.cs(2,6): error CS0122: 'Account.balance' is inaccessible due to its protection level
Why the compiler said that
balance is private, so only code written inside Account may touch it. This is the whole point: the class decides how its balance changes, and no caller can skip that logic.
The fix
Go through the public surface the class offers. If a change is legitimate, the class should expose a method for it (such as Deposit) that enforces its rules.
An init accessor lets a property be set while the object is being created (in a constructor or an object initializer) and never again: immutable after construction without writing a constructor. required goes one step further and makes the compiler reject any object creation that forgets to set it.
C#Program.cs
123456789101112
var order =newOrder{Id=42,Customer="Ana",Note="leave at door"};Console.WriteLine($"#{order.Id} for {order.Customer}: {order.Note}");var bare =newOrder{Id=43,Customer="Ben"};// Note is optionalConsole.WriteLine($"#{bare.Id} note is empty: {bare.Note == ""}");classOrder{publicrequiredintId{get;init;}publicrequiredstringCustomer{get;init;}publicstringNote{get;init;}="";}
Outputcompiled & run with real C#
#42 for Ana: leave at door
#43 note is empty: True
Error you will hit
CS8852: assigning an init-only property after creation
C#
1234567
var order =newOrder{Id=42};
order.Id =7;classOrder{publicintId{get;init;}}
Program.cs(2,1): error CS8852: Init-only property or indexer 'Order.Id' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
Why the compiler said that
init means "settable during construction only". Once new Order { ... } has finished, the object is frozen as far as Id is concerned.
The fix
Set it in the object initializer. If you genuinely need a changed copy, make Order a record and use order with { Id = 7 } (see the records lesson below).
C#
1234567
var order =newOrder{Id=7};Console.WriteLine(order.Id);classOrder{publicintId{get;init;}}
Error you will hit
CS9035: forgetting a required property
C#
1234567
var order =newOrder{Id=42};classOrder{publicrequiredintId{get;init;}publicrequiredstringCustomer{get;init;}}
Program.cs(1,17): error CS9035: Required member 'Order.Customer' must be set in the object initializer or attribute constructor.
Why the compiler said that
Both properties are required, and the initializer sets only Id. The compiler catches the half-built object at the line that creates it, instead of letting a null customer surface much later.
The fix
Set every required member in the initializer.
C#
12345678
var order =newOrder{Id=42,Customer="Ana"};Console.WriteLine(order.Customer);classOrder{publicrequiredintId{get;init;}publicrequiredstringCustomer{get;init;}}
03
Constructors, primary constructors and object initializers
✓
A constructor is the method that runs when you write new. It has the class's name and no return type, and its job is to leave the object in a valid state. A class can have several constructors (overloads); one can call another with : this(...) so the setup logic lives in one place. If you write no constructor, C# gives you a parameterless one that does nothing.
C#Program.cs
12345678910111213141516171819202122232425
var a =newRectangle(3,4);var b =newRectangle(5);// a square, via : this(side, side)var c =newRectangle();// default 1 x 1foreach(var r innew[]{ a, b, c })Console.WriteLine($"{r.Width} x {r.Height} = {r.Area}");classRectangle{publicintWidth{get;}publicintHeight{get;}publicintArea=>Width*Height;publicRectangle(int width,int height){if(width <=0|| height <=0)thrownewArgumentException("sides must be positive");Width= width;Height= height;}publicRectangle(int side):this(side, side){}publicRectangle():this(1){}}
Outputcompiled & run with real C#
3 x 4 = 12
5 x 5 = 25
1 x 1 = 1
A primary constructor (C# 12) puts the parameters on the class line itself: class Greeter(string greeting). The parameters are in scope in the whole class body, so you can use them in methods or copy them into properties. Unlike a record (below), a class with a primary constructor does not get public properties automatically: the parameters are private to the class.
An object initializer sets accessible properties right after the constructor runs: new Point { X = 1, Y = 2 }. It is shorthand for calling the constructor and then assigning each property, and it is the only way (besides a constructor) to set init properties.
C#Program.cs
123456789101112131415161718192021222324
var hi =newGreeter("Hello");Console.WriteLine(hi.Greet("Ana"));Console.WriteLine(hi.Greet("Ben"));Console.WriteLine(hi.Count);var card =newCard{Title="Intro",Pages=3};Console.WriteLine($"{card.Title} ({card.Pages} pages)");classGreeter(string greeting)// primary constructor{publicintCount{get;privateset;}publicstringGreet(string name){Count++;return $"{greeting}, {name}!";// parameter captured by the class}}classCard{publicstringTitle{get;set;}="Untitled";publicintPages{get;set;}}
Outputcompiled & run with real C#
Hello, Ana!
Hello, Ben!
2
Intro (3 pages)
Your turn
Give Greeter a second constructor public Greeter() : this("Hi") { } and create one with new Greeter().
Error you will hit
CS7036: no parameterless constructor exists any more
C#
1234567
var p =newPerson();classPerson{publicstringName{get;}publicPerson(string name)=>Name= name;}
Program.cs(1,13): error CS7036: There is no argument given that corresponds to the required parameter 'name' of 'Person.Person(string)'
Why the compiler said that
The free parameterless constructor exists only while you write none yourself. The moment Person(string name) exists, new Person() has no matching constructor, and the compiler reports the missing name argument.
The fix
Pass the argument, or add a parameterless constructor if an object without a name is really valid.
C#
12345678
var p =newPerson("Ana");Console.WriteLine(p.Name);classPerson{publicstringName{get;}publicPerson(string name)=>Name= name;}
04
Static members and static classes
✓
A static member belongs to the class itself, not to any one object. There is exactly one copy of a static field, shared by every instance, and you reach it through the class name: Ticket.Issued. Static methods cannot use this or instance fields, because there is no object. A static class can contain only static members and cannot be instantiated; Math and Console are static classes.
C#Program.cs
1234567891011121314151617181920212223242526
var t1 =newTicket("concert");var t2 =newTicket("museum");var t3 =newTicket("concert");Console.WriteLine($"{t1.Number} {t2.Number} {t3.Number}");Console.WriteLine($"issued: {Ticket.Issued}");Console.WriteLine(Units.KmToMiles(42.195).ToString("0.00"));classTicket{publicstaticintIssued{get;privateset;}// one shared counterpublicstringNumber{get;}publicTicket(string eventName){Issued++;Number= $"{eventName[..3].ToUpper()}-{Issued:000}";}}staticclassUnits{publicconstdoubleMilesPerKm=0.621371;publicstaticdoubleKmToMiles(double km)=> km *MilesPerKm;}
Outputcompiled & run with real C#
CON-001 MUS-002 CON-003
issued: 3
26.22
Static state is global state
A static field is effectively a global variable: shared by every object, every request in a web app and every test. Use static for constants, pure helper methods and caches you mean to share. Do not put per-user or per-request data in a static field; in a web server that is how one user sees another's data.
Error you will hit
CS0120: using an instance member from a static method
C#
1234567
Console.WriteLine(Counter.Report());classCounter{privateint count =5;publicstaticstringReport()=> $"count is {count}";}
Program.cs(6,50): error CS0120: An object reference is required for the non-static field, method, or property 'Counter.count'
Why the compiler said that
Report is static, so it runs without any Counter object, but count is an instance field: each object has its own. With no object, there is no count to read.
The fix
Either make Report an instance method and call it on an object, or make the data static too if it really is shared.
C#
12345678
var c =newCounter();Console.WriteLine(c.Report());classCounter{privateint count =5;publicstringReport()=> $"count is {count}";}
05
Records: value equality and with-expressions
✓
A record is a type built for holding data. One line, record Money(decimal Amount, string Currency);, gives you a constructor, public init-only properties, deconstruction, a readable ToString and, most importantly, value equality: two records are equal when all their properties are equal, even if they are different objects. Two ordinary class instances are equal only when they are the very same object.
C#Program.cs
12345678910111213141516171819202122
var a =newMoney(10m,"USD");var b =newMoney(10m,"USD");Console.WriteLine(a);Console.WriteLine($"records equal: {a == b}");var c1 =newMoneyClass(10m,"USD");var c2 =newMoneyClass(10m,"USD");Console.WriteLine($"classes equal: {c1 == c2}");var raised = a with{Amount=15m};// copy with one property changedConsole.WriteLine($"{a.Amount} -> {raised.Amount} {raised.Currency}");var(amount, currency)= raised;// deconstruction for freeConsole.WriteLine($"{amount} {currency}");recordMoney(decimalAmount,stringCurrency);classMoneyClass(decimal amount,string currency){publicdecimalAmount{get;}= amount;publicstringCurrency{get;}= currency;}
Add a method to Money: public Money Add(Money other) => this with { Amount = Amount + other.Amount }; and print a.Add(b).
record (or record class) is a reference type; record struct is a value type with the same conveniences.
Records are immutable by default because positional properties are init-only. with is how you "change" one: it copies and overrides.
You can add methods, extra properties and validation to a record like any class.
Use records for DTOs, API request/response shapes, messages, value objects (money, coordinates) and dictionary keys. Use a class for things with identity and changing state (an account, a connection).
with makes a shallow copy
If a record holds a List<T>, with copies the reference to the list, not the list. Both records then share and mutate the same list. Prefer immutable collections (or arrays you never change) inside records.
06
Structs versus classes: copy semantics
✓
A struct is a value type: a variable holds the data itself, and assignment copies all of it. A class is a reference type: a variable holds a reference, and assignment copies the reference, so two variables can point at one object. int, double, bool, DateTime and tuples are structs; string, arrays and List<T> are classes.
VisualizeCopying a struct versus copying a classStep 1 / 7
1var p1 = new PointS { X =1};
2var p2 = p1;
3p2.X =99;
4var r1 = new PointC { X =1};
5var r2 = r1;
6r2.X =99;
7Console.WriteLine($"{p1.X} {r1.X}");
8
9struct PointS { public int X;}
10class PointC { public int X;}
Line 1
p1 holds a struct directly: the X value lives in the variable.
Variables now
p1.X
1
All 7 steps as a table
Step
Line
What happened
Variables now
1
1
p1 holds a struct directly: the X value lives in the variable.
p1.X = 1
2
2
Assigning a struct copies every field. p2 is an independent copy.
p1.X = 1p2.X = 1
3
3
Changing the copy leaves the original alone.
p1.X = 1p2.X = 99
4
4
r1 holds a reference to a new object on the heap.
r1 -> obj.X = 1
5
5
Assigning a class copies the reference only: r2 points at the same object.
r1 -> obj.X = 1r2 -> obj.X = 1
6
6
Writing through r2 changes the one shared object, so r1 sees it too.
r1 -> obj.X = 99r2 -> obj.X = 99
7
7
The struct kept its value; the class did not.
C#Program.cs
12345678910111213141516
var points =newList<PointS>{newPointS(1,2)};var first = points[0];// a COPY of the struct
first.X =50;Console.WriteLine(points[0].X);// the list is unchanged
points[0]= first;// write the copy backConsole.WriteLine(points[0].X);PointS origin =default;// all fields zero, never nullConsole.WriteLine($"({origin.X}, {origin.Y})");structPointS(int x,int y){publicint X = x;publicint Y = y;}
Outputcompiled & run with real C#
1
50
(0, 0)
Reading a struct out of a list or dictionary gives you a copy. This surprises everyone once; it is one reason mutable structs are discouraged.
struct
class
Kind
Value type
Reference type
Assignment / passing
Copies all the data
Copies the reference
Can be null
No (unless Nullable<T>, e.g. int?)
Yes
Inheritance
Cannot inherit or be inherited (can implement interfaces)
Single inheritance
Default equality
Field by field
Same object (reference equality)
Use for
Small (up to about 16 bytes), immutable values: coordinates, colours, money
Almost everything else
In real jobs
Most C# code you write will be classes and records. Declare a struct only for small, immutable values, ideally readonly struct or readonly record struct so nobody can mutate a copy by mistake. Large structs make every assignment and method call copy the whole thing.
07
ToString, Equals and GetHashCode
✓
Every type inherits three methods from object that you often override. ToString() is what string interpolation and Console.WriteLine call; for a class the default prints only the type name. Equals(object) decides whether two objects count as the same value; for a class the default is "same object". GetHashCode() picks the bucket in a Dictionary or HashSet, and it must agree with Equals: equal objects must return equal hash codes.
C#Program.cs
12345678910111213141516171819202122
var a =newIsbn("978-0134494166");var b =newIsbn("9780134494166");// same book, no dashesConsole.WriteLine(a);Console.WriteLine($"Equals: {a.Equals(b)}");var shelf =newHashSet<Isbn>{ a };Console.WriteLine($"shelf has b: {shelf.Contains(b)}");classIsbn{publicstringDigits{get;}publicIsbn(string text)=>Digits= text.Replace("-","");publicoverridestringToString()=> $"ISBN {Digits}";publicoverrideboolEquals(object? obj)=>
obj isIsbn other && other.Digits ==Digits;publicoverrideintGetHashCode()=>Digits.GetHashCode();}
Outputcompiled & run with real C#
ISBN 9780134494166
Equals: True
shelf has b: True
Your turn
Delete the GetHashCode override (keep Equals). The compiler warns (CS0659). Does shelf.Contains(b) still print True? Why not?
For several fields, combine them with HashCode.Combine(Name, Year) rather than inventing arithmetic. Or skip the ceremony: a record generates correct Equals, GetHashCode, ==, != and ToString from its properties. Override by hand only when equality is not "all fields equal", as with the dashes above.
Never use mutable fields in GetHashCode
If an object is in a HashSet and you change a field its hash code depends on, it is now in the wrong bucket: Contains returns false for an object that is plainly in the set. Hash only on data that never changes after construction.
08
Nullable reference types and NullReferenceException
✓
Any reference-type variable can hold null, meaning "no object". Calling a member on null throws a NullReferenceException, historically the most common crash in .NET. Nullable reference types (on by default in new projects and in file-based apps) make the compiler track it: string means "never null", string? means "might be null", and the compiler warns when you dereference a string? without checking.
C#Program.cs
12345678910111213141516171819
string? nickname =FindNickname("ben");// Three safe ways to use a string? valueif(nickname isnotnull)Console.WriteLine($"length {nickname.Length}");int? length = nickname?.Length;// ?. short-circuits to nullConsole.WriteLine($"has a length: {length.HasValue}");Console.WriteLine($"shown as: {nickname ?? "(none)"}");// ?? supplies a fallbackvar ana =newUser("Ana",FindNickname("ana"));Console.WriteLine(ana.Display);staticstring?FindNickname(string user)=> user =="ana"?"Annie":null;classUser(string name,string? nickname){publicstringDisplay=> nickname isnull? name : $"{name} ({nickname})";}
Outputcompiled & run with real C#
has a length: False
shown as: (none)
Ana (Annie)
Error you will hit
NullReferenceException: calling a member on null
C#
123
var names =newDictionary<int,string?>{[1]="Ana",[2]=null};foreach(var(id, name)in names)Console.WriteLine($"{id}: {name.ToUpper()}");
Program.cs(3,32): warning CS8602: Dereference of a possibly null reference.
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<Main>$(String[] args) in Program.cs:line 3
Why the compiler said that
Entry 2 holds null. The first iteration works, then name.ToUpper() runs on null and the runtime throws. Look at the build output too: the compiler printed warning CS8602: Dereference of a possibly null reference for that exact line. Nullable warnings are the compiler predicting this crash; treat them as errors (<TreatWarningsAsErrors> or at least <WarningsAsErrors>nullable</WarningsAsErrors> in the project file).
The fix
Decide what a missing name means and handle it: ?. plus ?? for a fallback, or an explicit if. Use the null-forgiving ! operator only when you know better than the compiler, never to silence it.
C#
123
var names =newDictionary<int,string?>{[1]="Ana",[2]=null};foreach(var(id, name)in names)Console.WriteLine($"{id}: {name?.ToUpper() ?? "(no name)"}");
Warnings, not errors
Nullable reference types are a compile-time analysis only: string and string? are the same type at runtime, and the compiler reports warnings, not errors, so the code above still builds and crashes. That is why teams turn the nullable warnings into errors in CI.
Class
A reference type that bundles data (fields, properties) and behaviour (methods).
Object / instance
One value created from a class with new; it has its own copy of every instance field.
Property
A member that looks like a field but runs get/set accessor code; auto-properties have a compiler-written backing field.
Encapsulation
Keeping data private and exposing only members that enforce the object's rules.
init / required
init: settable only during construction. required: must be set when the object is created.
Primary constructor
Constructor parameters written on the type line (class C(int x)), in scope throughout the body.
Static member
Belongs to the type, not an instance; one shared copy.
Record
A type with compiler-generated value equality, ToString, deconstruction and with-expressions.
Value type / reference type
Structs are copied on assignment; classes share one object through copied references.
Nullable reference types
Compile-time tracking of which references may be null (string?) and which may not (string).
Quick check
record Point(int X, int Y); What does new Point(1, 2) == new Point(1, 2) evaluate to, and what would it be if Point were a plain class with the same properties?
Records compare by value: same property values means equal. A class compares by reference unless you override Equals and ==, and two new expressions create two different objects.
Quick check
A struct Counter { public int N; } is stored in a List<Counter>. You write var c = list[0]; c.N++;. What is list[0].N afterwards?
Reading a struct from a list returns a copy. Incrementing the copy does not touch the element in the list until you assign it back with list[0] = c.
What is the difference between a class and a record in C#?
Both are reference types, but a record compares by value (two records with the same property values are equal), generates ToString and deconstruction, supports with-expressions, and its positional properties are init-only. Use records for data and classes for objects with identity and changing state.
When should I use a struct instead of a class in C#?
For small, immutable values that are copied often, such as coordinates or money, ideally as a readonly struct or readonly record struct. Structs are copied on every assignment, cannot be null and cannot inherit, so most types should be classes or records.
How do I avoid NullReferenceException in C#?
Keep nullable reference types enabled, declare possibly-missing values as string? and handle them with if checks, ?. and ??, and treat the compiler's nullable warnings (such as CS8602) as errors so they fail the build before they crash in production.
Finish the C# handbook, then get hired
Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.