Object-Oriented Programming
Reviewed & published by Brayan K
By the end of this lesson you'll be able to design your own types — bundling data and behaviour into classes, building objects from them, initialising them with constructors, and protecting their data with encapsulation. This is the way real C# programs are organised.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Tell the difference between a class (blueprint) and an object (instance)
- Create classes with fields and methods, and build objects with new
- Write constructors that initialise objects in one line — no invalid state
- Use this to refer to the current object inside its own methods
- Apply encapsulation: private fields, public methods, and access modifiers
- Use properties (auto, validated, computed) and tell static from instance members
💡 Real-World Analogy
A class is like an architectural blueprint for a house. It defines what rooms exist, where the doors go, and how the plumbing works — but it's not a house you can live in. An object is an actual house built from that blueprint. You can build hundreds of houses from one blueprint, each with its own paint colour (field values), yet they all share the same structure (methods and properties). A constructor is the building crew that sets each house up the day it's finished. And encapsulation is the walls and the wiring behind them: you flick the light switches (public methods), but you can't grab the live wires directly (private fields).
Understanding OOP in C#
Object-Oriented Programming (OOP) is a way of organising code around objects — units that bundle data (what something is) and behaviour (what something does). Instead of one long script, you model your program as a set of cooperating objects, much like the real world.
C# is built around OOP — nearly everything lives inside a class. There are four classic pillars:
- 🔒 Encapsulation — hide internal data, expose controlled access (this lesson)
- 🧬 Inheritance — build specialised classes from general ones (next lesson)
- 🎭 Polymorphism — the same method name behaving differently per object
- 🎨 Abstraction — describe "what" without the "how" (interfaces & abstract classes)
This lesson covers the foundations and the first pillar — encapsulation — alongside classes, objects, constructors, properties, and static members. These are the building blocks of every C# project from here on.
📊 Access Modifiers Quick Reference
| Modifier | Who can access it | Use when |
|---|---|---|
| public | Anywhere | Methods and properties other code needs to call |
| private | Only inside the same class | Internal data and helpers (the default for fields) |
| protected | Same class + derived classes | Data subclasses need but outside code shouldn't touch |
| internal | Same assembly (project) | Shared within a project but hidden from outside consumers |
If you don't write a modifier on a class member, it defaults to private. Get into the habit of being explicit — it makes your intent obvious to the next reader.
1. Classes and Objects
A class is the blueprint — it describes the fields (the data each object holds) and the methods (what each object can do). An object is a specific thing built from that blueprint with its own values; you create one with the new keyword. The class is written once; you can build as many objects from it as you like, and each keeps its own data. Read this worked example, run it, then you'll finish a class yourself.
using System;
// A class is a BLUEPRINT — it describes what every Car will have and do.
// It is not a car you can drive; it's the design on paper.
class Car
{
// Fields = the data each Car object holds.
public string Brand;
public string Model;
public int Year;
// A method = something the object can DO.
public void StartEngine()
{
Console.WriteLine($"{Brand} {Model} ({Year}) — engine started! 🚗");
}
}
class Program
{
static void Main()
{
// 'new' builds an OBJECT (an instance) from the Car blueprint.
Car car1 = new Car(); // a real car now exists in memory
car1.Brand = "Toyota"; // set this object's own data
car1.Model = "Camry";
car1.Year = 2023;
Car car2 = new Car(); // a SECOND, independent object
car2.Brand = "Tesla";
car2.Model = "Model 3";
car2.Year = 2024;
car1.StartEngine(); // Toyota Camry (2023) — engine started! 🚗
car2.StartEngine(); // Tesla Model 3 (2024) — engine started! 🚗
// Each object keeps its OWN field values — they don't share data.
Console.WriteLine($"car1 brand: {car1.Brand}"); // car1 brand: Toyota
}
}
// ✅ Expected output:
// Toyota Camry (2023) — engine started! 🚗
// Tesla Model 3 (2024) — engine started! 🚗
// car1 brand: ToyotaYour turn. The Rectangle class below is almost complete — it just needs a second field and a one-line method body. Fill in the two ___ blanks, then run it.
using System;
// 🎯 YOUR TURN — finish the Rectangle class, then run it.
class Rectangle
{
// 1) Width is done for you. Add a public int field called Height below it.
public int Width;
public int ___; // 👉 name this field Height
// 2) Area() should return Width times Height.
public int Area()
{
return ___; // 👉 multiply the two fields: Width * Height
}
}
class Program
{
static void Main()
{
Rectangle r = new Rectangle();
r.Width = 4;
r.Height = 3;
Console.WriteLine($"Area = {r.Area()}");
// ✅ Expected output:
// Area = 12
}
}2. Constructors & this
Setting every field by hand after new is tedious and easy to forget — leaving an object half-built. A constructor fixes that: it's a special method with the same name as the class that runs automatically when you write new ClassName(...), so you create and initialise the object in one line. Inside it, this means "the object being built right now" — handy when a parameter has the same name as a field (this.Brand = brand;).
using System;
class Car
{
public string Brand;
public string Model;
public int Year;
// A CONSTRUCTOR runs automatically when you write 'new Car(...)'.
// It has the same name as the class and no return type.
// 'this.Brand' = THIS object's Brand; 'brand' = the value passed in.
public Car(string brand, string model, int year)
{
this.Brand = brand; // 'this' makes it clear which is which
this.Model = model;
this.Year = year;
}
public void StartEngine()
{
Console.WriteLine($"{Brand} {Model} ({Year}) — engine started! 🚗");
}
}
class Program
{
static void Main()
{
// Now ONE line creates the object AND fills its data — no invalid state.
Car car = new Car("Toyota", "Camry", 2023);
car.StartEngine(); // Toyota Camry (2023) — engine started! 🚗
Console.WriteLine($"Year: {car.Year}"); // Year: 2023
}
}
// ✅ Expected output:
// Toyota Camry (2023) — engine started! 🚗
// Year: 2023Now you try. Finish the Dog constructor so it stores the name, then create a dog and make it bark. Fill in the three ___ blanks:
using System;
class Dog
{
public string Name;
public string Breed;
// 🎯 YOUR TURN — finish the constructor and the Main method.
// 1) Set this object's fields from the values passed in.
public Dog(string name, string breed)
{
this.Name = ___; // 👉 assign the 'name' parameter to this.Name
this.Breed = breed; // (this one is done for you)
}
public void Bark()
{
Console.WriteLine($"{Name} the {Breed} says Woof! 🐶");
}
}
class Program
{
static void Main()
{
// 2) Create a Dog called "Rex" whose breed is "Labrador".
Dog d = new ___("Rex", "Labrador"); // 👉 the class name: Dog
// 3) Make it bark.
d.___(); // 👉 call the method Bark
// ✅ Expected output:
// Rex the Labrador says Woof! 🐶
}
}3. Encapsulation — Protecting Data
Encapsulation means hiding a class's data behind a controlled gate. You mark fields private so outside code can't touch them directly, and you offer public methods as the only way in. Those methods can enforce the rules — a bank account can refuse a negative deposit or an overdraft. The payoff is twofold: invalid data is impossible, and you can change how the class works inside without breaking any code that uses it.
using System;
class BankAccount
{
// 'private' means: only code INSIDE this class can touch balance.
// Outside code cannot read or change it directly — that's the point.
private double balance;
public string Owner { get; } // read-only from outside
public BankAccount(string owner, double initialBalance)
{
Owner = owner;
balance = initialBalance;
}
// Public methods are the ONLY way in — so they can enforce the rules.
public void Deposit(double amount)
{
if (amount <= 0)
{
Console.WriteLine("❌ Deposit must be positive.");
return; // reject bad input
}
balance += amount;
Console.WriteLine($"✅ Deposited £{amount:F2}. Balance: £{balance:F2}");
}
public bool Withdraw(double amount)
{
if (amount <= 0 || amount > balance) // can't overdraw
{
Console.WriteLine("❌ Invalid withdrawal.");
return false;
}
balance -= amount;
Console.WriteLine($"✅ Withdrew £{amount:F2}. Balance: £{balance:F2}");
return true;
}
public double GetBalance() => balance; // a controlled way to read it
}
class Program
{
static void Main()
{
var account = new BankAccount("Alice", 1000);
account.Deposit(500); // ✅ Deposited £500.00. Balance: £1500.00
account.Withdraw(200); // ✅ Withdrew £200.00. Balance: £1300.00
account.Withdraw(99999); // ❌ Invalid withdrawal. (rule enforced!)
// account.balance = 999999; // ❌ Won't compile — balance is private!
Console.WriteLine($"Final balance: £{account.GetBalance():F2}"); // £1300.00
}
}
// ✅ Expected output:
// ✅ Deposited £500.00. Balance: £1500.00
// ✅ Withdrew £200.00. Balance: £1300.00
// ❌ Invalid withdrawal.
// Final balance: £1300.004. Properties — Fields vs Properties
A field is raw stored data; a property looks like a field from outside but runs code when read or written, which lets you add validation without changing how callers use it. An auto-property — public string Name { get; set; } — is the everyday default and the compiler creates the hidden storage for you. For checks, use a full property with a private backing field; for values derived on the fly, use a computed property with =>; and leave out set to make a property read-only.
using System;
class Student
{
// Auto-property — the compiler creates the hidden storage for you.
// This is the everyday default when no validation is needed.
public string Name { get; set; }
// Full property — a 'backing field' lets you validate the value.
private int age;
public int Age
{
get => age; // returning the stored value
set // 'value' is whatever was assigned
{
if (value < 0 || value > 150)
throw new ArgumentException("Age must be 0–150");
age = value; // only stored if valid
}
}
// Read-only property — has a 'get' but no 'set', so it can only be
// assigned in the constructor and never changed afterwards.
public string StudentId { get; }
// Computed property — no storage; it calculates each time it's read.
public bool IsAdult => Age >= 18;
public Student(string id, string name, int age)
{
StudentId = id;
Name = name;
Age = age; // runs through the validating setter
}
}
class Program
{
static void Main()
{
var student = new Student("S001", "Alice", 20);
Console.WriteLine($"{student.Name} (ID: {student.StudentId})"); // Alice (ID: S001)
Console.WriteLine($"Age: {student.Age}, Adult: {student.IsAdult}"); // Age: 20, Adult: True
student.Name = "Alice Smith"; // setter works
student.Age = 21; // validated setter accepts 21
// student.StudentId = "NEW"; // ❌ Won't compile — read-only!
Console.WriteLine($"Updated: {student.Name}, Age {student.Age}"); // Alice Smith, Age 21
}
}
// ✅ Expected output:
// Alice (ID: S001)
// Age: 20, Adult: True
// Updated: Alice Smith, Age 21🔎 Deep Dive: why properties instead of public fields?
It's tempting to just write public string name; and be done. The trouble is the day you need a rule — "names can't be empty" — you'd have to add validation, which means turning the field into a property, which changes its type of member and can break code (and reflection/serialisation) that relied on it being a field.
A property future-proofs you: it already looks like obj.Name = "Al"; to callers, but you can slip validation into the setter later without changing a single line of calling code.
public string Name { get; set; } // start simple
public string Name // add a rule later — callers unchanged
{
get => _name;
set => _name = string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Name required")
: value;
}Convention: properties are PascalCase (FirstName), private backing fields are camelCase or underscore-prefixed (firstName or _firstName).
5. Static vs Instance Members
Instance members belong to each object — every car has its own brand. Static members belong to the class itself and are shared by every object — perfect for things like "how many cars exist in total." You access an instance member through an object (car1.Brand) and a static member through the class name (Counter.GetTotalCount()). If a value should be the same for every object, make it static; if each object needs its own copy, keep it instance.
using System;
class Counter
{
// STATIC field — ONE copy shared by every Counter object that exists.
private static int totalCount = 0;
// INSTANCE field — every object gets its OWN Label.
public string Label { get; }
public Counter(string label)
{
Label = label;
totalCount++; // bumped each time ANY object is made
}
// STATIC method — called on the CLASS, not on an object.
public static int GetTotalCount() => totalCount;
// INSTANCE method — called on a specific object.
public void Display() => Console.WriteLine($"Counter: {Label}");
}
class Program
{
static void Main()
{
Console.WriteLine($"Count before: {Counter.GetTotalCount()}"); // Count before: 0
var c1 = new Counter("Alpha");
var c2 = new Counter("Beta");
var c3 = new Counter("Gamma");
c1.Display(); // Counter: Alpha (instance method, on the object)
c2.Display(); // Counter: Beta
// Static is on the CLASS name — the shared count is now 3.
Console.WriteLine($"Count after: {Counter.GetTotalCount()}"); // Count after: 3
// Counter.Label; // ❌ can't read an instance member on the class
// c1.GetTotalCount(); // ⚠️ avoid — call Counter.GetTotalCount() instead
}
}
// ✅ Expected output:
// Count before: 0
// Counter: Alpha
// Counter: Beta
// Count after: 3Putting It Together: a Playlist Class
Here's a small but real class that uses everything from this lesson at once — a constructor, a read-only property, a private field guarded by a public method, a computed property, and a static counter shared across every playlist. You understand every line now.
using System;
// === A Playlist class — uses everything from this lesson at once ===
class Playlist
{
public string Name { get; } // read-only property, set in constructor
private int trackCount; // private field — controlled access only
public static int PlaylistsMade { get; private set; } // static, shared count
public Playlist(string name)
{
Name = name;
trackCount = 0;
PlaylistsMade++; // every new playlist bumps the shared total
}
public void AddTrack()
{
trackCount++;
Console.WriteLine($"Added a track to '{Name}' — now {trackCount} track(s).");
}
// Computed property: derives a value from the private field.
public bool IsEmpty => trackCount == 0;
}
class Program
{
static void Main()
{
var workout = new Playlist("Workout");
workout.AddTrack(); // Added a track to 'Workout' — now 1 track(s).
workout.AddTrack(); // Added a track to 'Workout' — now 2 track(s).
var chill = new Playlist("Chill");
Console.WriteLine($"'{chill.Name}' empty? {chill.IsEmpty}"); // 'Chill' empty? True
// Static member is shared across ALL playlists.
Console.WriteLine($"Playlists made: {Playlist.PlaylistsMade}"); // Playlists made: 2
}
}
// ✅ Expected output:
// Added a track to 'Workout' — now 1 track(s).
// Added a track to 'Workout' — now 2 track(s).
// 'Chill' empty? True
// Playlists made: 2Notice PlaylistsMade is static with a private set — outside code can read the total but only the class itself can change it. That's encapsulation applied to a static member.
Pro Tips
- 💡 Default to private fields, public properties: this is the standard C# convention. Don't expose a field as public unless you have a strong reason.
- 💡 Use auto-properties for simple data: public string Name { get; set; } is cleaner than a full backing field when there's no validation to do.
- 💡 Prefer init-only setters for immutable data: in C# 9+, public string Id { get; init; } allows setting only during object creation.
- 💡 Initialise objects in the constructor so they're never in a half-built, invalid state — that's the whole reason constructors exist.
- 💡 Reach for records for plain data carriers: record Person(string Name, int Age); auto-generates equality, ToString, and deconstruction.
Common Errors (and the fix)
- "CS0246: The type or namespace name 'X' could not be found": the class you're using doesn't exist or isn't in scope — usually a misspelt class name or a missing using directive. Check the spelling and add the right using at the top.
- "System.NullReferenceException: Object reference not set to an instance of an object": you used an object that was never created with new, so it's null. Car c; c.StartEngine(); crashes — write Car c = new Car(...); first.
- Forgetting new: declaring Car c; only makes a variable that points at nothing. You must actually build the object: c = new Car(...); before you can use it.
- "CS0122: 'BankAccount.balance' is inaccessible due to its protection level": you tried to read or set a private member from outside the class. Go through a public method or property instead — that's encapsulation doing its job.
- "CS1061: 'Car' does not contain a definition for 'StartEngine'" when calling on the class: you wrote Car.StartEngine() on an instance method. Call it on an object (car1.StartEngine()), or make the member static if it truly belongs to the class.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Define a class | class Car { ... } | The blueprint |
| Create an object | var c = new Car(...); | new builds an instance |
| Constructor | public Car(string b) { ... } | Same name as class |
| This object | this.Brand = brand; | Current instance |
| Auto-property | public string Name { get; set; } | Storage created for you |
| Read-only property | public int Id { get; } | Set in constructor only |
| Computed property | public bool IsAdult => Age >= 18; | No backing field |
| Static member | Counter.GetTotalCount() | On the class, shared |
Frequently Asked Questions
Q: What's the actual difference between a class and an object?
A class is the design — written once, it lists the fields and methods. An object is a real thing built from that design with new, holding its own values. Blueprint vs house: one blueprint, many houses.
Q: When should I use a field versus a property?
Use a private field for internal storage, and expose data through a property rather than a public field. Properties look like fields to callers but let you add validation or change the implementation later without breaking anyone.
Q: Why did I get a NullReferenceException?
You used an object variable that was never assigned with new, so it points at null. Create the object first (var c = new Car(...);) before calling its methods or reading its data.
Q: When should something be static instead of instance?
Make it static when the value or behaviour belongs to the class as a whole and is shared by every object — like a running count of how many objects exist, or a utility method that doesn't need any object's data. If each object needs its own copy, keep it instance.
Mini-Challenge: Build a BankAccount
No blanks this time — just a brief and an outline to keep you on track. Build a BankAccount class with a private balance, a constructor, Deposit and Withdraw methods that enforce the rules, and a way to read the balance — then exercise it in Main. Run it and check your output against the expected line in the comments.
using System;
// 🎯 MINI-CHALLENGE: Build a BankAccount class
// 1. Give it a private double field called 'balance'.
// 2. Add a constructor that takes a starting balance and stores it.
// 3. Add Deposit(double amount): add amount to balance (ignore amounts <= 0).
// 4. Add Withdraw(double amount): subtract it ONLY if amount <= balance,
// otherwise print "Insufficient funds".
// 5. Add a Balance property (or GetBalance() method) to read the balance.
//
// In Main: make an account with 100, Deposit 50, Withdraw 30, Withdraw 999,
// then print the final balance.
//
// ✅ Expected final balance:
// Balance: £120.00 (100 + 50 - 30, the £999 is refused)
class BankAccount
{
// your fields, constructor and methods here
}
class Program
{
static void Main()
{
// your code here
}
}🎉 Lesson Complete
- ✅ A class is a blueprint; an object is an instance built with new
- ✅ Fields hold data, methods do work — a class bundles both together
- ✅ Constructors initialise an object in one line; this means the current object
- ✅ Encapsulation = private fields + public methods/properties for controlled access
- ✅ Properties (auto, validated, computed) are the C# way to expose data safely
- ✅ Static members belong to the class and are shared; instance members are per-object
- ✅ Access modifiers: public, private, protected, internal
Practice quiz
What is the relationship between a class and an object?
- They are identical
- An object is a blueprint; a class is an instance
- A class is a blueprint; an object is an instance built from it
- A class can have only one object
Answer: A class is a blueprint; an object is an instance built from it. A class is the design (blueprint); an object is a real instance created from it with new.
Which keyword creates an object from a class?
- new
- create
- make
- object
Answer: new. The new keyword builds an instance, e.g. var c = new Car();.
What is special about a constructor?
- It returns a value
- It must be private
- It is called manually after new
- It has the same name as the class and runs when you write new
Answer: It has the same name as the class and runs when you write new. A constructor shares the class's name, has no return type, and runs automatically on new.
What does the this keyword refer to inside a method?
- The base class
- The current object instance
- A static field
- The class itself
Answer: The current object instance. this means the object the method is being called on — useful when a parameter shares a field's name.
What does the private access modifier mean?
- Accessible only inside the same class
- Accessible anywhere
- Accessible by derived classes
- Accessible within the project
Answer: Accessible only inside the same class. private members can be touched only by code inside the same class — the core of encapsulation.
What is an auto-property like public string Name { get; set; } ?
- A private field
- A static method
- A property whose backing storage the compiler creates for you
- A constructor
Answer: A property whose backing storage the compiler creates for you. An auto-property has the compiler generate the hidden backing field automatically.
A property with a get but no set is what?
- Write-only
- Read-only (assignable only in the constructor)
- Static
- Computed
Answer: Read-only (assignable only in the constructor). With only a get, the property is read-only and can be set just in the constructor.
What does a static member belong to?
- Each individual object
- Only the constructor
- The base class only
- The class itself, shared by all objects
Answer: The class itself, shared by all objects. A static member belongs to the class and is shared across every instance.
How do you call a static method like GetTotalCount on class Counter?
- counter1.GetTotalCount()
- Counter.GetTotalCount()
- new Counter().GetTotalCount()
- static GetTotalCount()
Answer: Counter.GetTotalCount(). Static members are accessed through the class name: Counter.GetTotalCount().
What is the main benefit of using a property instead of a public field?
- It runs faster
- It uses less memory
- You can add validation later without changing calling code
- It is always static
Answer: You can add validation later without changing calling code. A property looks like a field to callers but lets you slip in validation or change the implementation later.
Continue this course
- Previous: Methods
- Next: Inheritance & Polymorphism — Extend base classes and override methods with virtual and override
- Quick reference: C# cheat sheet › OOP