Home C# / .NET Classes and Objects in C# Explained — Blueprint, Instance and Real-World Patterns

Classes and Objects in C# Explained — Blueprint, Instance and Real-World Patterns

In Plain English 🔥
Think of a class like a cookie cutter. The cutter itself isn't a cookie — it's just the shape. Every time you press it into dough you get a real cookie: that's an object. You can make a hundred cookies from the same cutter, each one separate, each one able to have different toppings. The cutter is your class. The cookies are your objects. That's the whole idea.
⚡ Quick Answer
Think of a class like a cookie cutter. The cutter itself isn't a cookie — it's just the shape. Every time you press it into dough you get a real cookie: that's an object. You can make a hundred cookies from the same cutter, each one separate, each one able to have different toppings. The cutter is your class. The cookies are your objects. That's the whole idea.

Every non-trivial C# application you'll ever work on — a web API, a game, a desktop app — is built from objects talking to each other. Classes are the backbone of that system. Skipping a real understanding of them means you'll write code that works today but collapses under its own weight next month when requirements change.

What a Class Actually IS — and Why You Need One

A class is a blueprint that bundles two things together: data (what something knows) and behaviour (what something can do). Before classes existed in mainstream languages, data and the functions that operated on it lived separately. You'd pass a customer record into a dozen different functions, and nothing stopped someone from passing the wrong data to the wrong function. Classes solved that by packaging the data and its operations into one sealed unit — a concept called encapsulation.

In C#, a class is a reference type, which means when you create an object from it, the variable you declare doesn't hold the object directly — it holds a reference (a pointer) to where the object lives on the heap. This distinction matters enormously when you start passing objects between methods and wondering why your original data changed.

Think of it this way: a class defines WHAT a bank account looks like. An object IS a specific bank account — say, account number 100234 belonging to Sarah, holding £4,200. You can have millions of accounts (objects) all sharing the same shape (class) without any of them interfering with each other.

BankAccount.cs · CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
using System;

// The CLASS — this is the blueprint. It exists at compile time.
// It describes what every bank account will look like.
public class BankAccount
{
    // Fields: the DATA this object holds.
    // 'private' means only code inside this class can touch them directly.
    private string _accountHolder;
    private decimal _balance;

    // Constructor: special method that runs when you create a new object.
    // It guarantees every account starts in a known, valid state.
    public BankAccount(string accountHolder, decimal openingBalance)
    {
        if (string.IsNullOrWhiteSpace(accountHolder))
            throw new ArgumentException("Account holder name cannot be empty.");

        if (openingBalance < 0)
            throw new ArgumentException("Opening balance cannot be negative.");

        _accountHolder = accountHolder;
        _balance = openingBalance;
    }

    // Properties: controlled gateways to read (and sometimes write) fields.
    // Callers can READ the balance but never SET it directly — only Deposit/Withdraw can.
    public string AccountHolder => _accountHolder;
    public decimal Balance => _balance;

    // Methods: the BEHAVIOUR — things this object can DO.
    public void Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Deposit amount must be positive.");

        _balance += amount;  // Only this method can legitimately increase the balance
        Console.WriteLine($"{_accountHolder} deposited {amount:C}. New balance: {_balance:C}");
    }

    public bool Withdraw(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Withdrawal amount must be positive.");

        if (amount > _balance)
        {
            Console.WriteLine($"Insufficient funds. {_accountHolder} tried to withdraw {amount:C}.");
            return false;  // Signals failure without throwing an exception
        }

        _balance -= amount;
        Console.WriteLine($"{_accountHolder} withdrew {amount:C}. New balance: {_balance:C}");
        return true;
    }

    // ToString override: makes Console.WriteLine(account) print something useful
    public override string ToString()
    {
        return $"Account[{_accountHolder}, Balance: {_balance:C}]";
    }
}

class Program
{
    static void Main()
    {
        // OBJECT CREATION — 'new' allocates memory on the heap and calls the constructor.
        // sarahAccount is a REFERENCE to that object, not the object itself.
        BankAccount sarahAccount = new BankAccount("Sarah", 1000m);
        BankAccount jamesAccount = new BankAccount("James", 500m);

        // Each object has its own independent copy of _balance.
        // Changing Sarah's account has zero effect on James's.
        sarahAccount.Deposit(250m);
        sarahAccount.Withdraw(100m);
        jamesAccount.Withdraw(600m);  // This will fail — insufficient funds

        Console.WriteLine();
        Console.WriteLine(sarahAccount);  // Calls our ToString() override
        Console.WriteLine(jamesAccount);
    }
}
▶ Output
Sarah deposited £250.00. New balance: £1,250.00
Sarah withdrew £100.00. New balance: £1,150.00
Insufficient funds. James tried to withdraw £600.00.

Account[Sarah, Balance: £1,150.00]
Account[James, Balance: £500.00]
⚠️
Pro Tip: Always validate in the constructorYour constructor is the bouncer at the door. An object that's created in an invalid state (negative balance, null name) will cause mysterious bugs far away from where the real problem started. Validate eagerly and throw early — it makes debugging infinitely easier.

Constructors, Properties and Access Modifiers — The Holy Trinity of Encapsulation

Encapsulation isn't about hiding data for the sake of it — it's about controlling the narrative around your object's state. When you make a field public, you're telling every other class in your codebase: 'feel free to change this however you like, whenever you like.' That's a promise you'll regret when a bug sets a product price to -£99.

Properties in C# are the elegant middle ground. They look like fields to the caller but act like methods under the hood — letting you add validation, logging or lazy loading without breaking any existing code. A get-only property (like public decimal Balance => _balance;) makes that value readable but completely immutable from the outside.

Access modifiers enforce your design decisions at compile time. private means only this class. protected means this class and its children. public means anyone. internal means anyone in the same assembly. The default for class members is private — which is exactly right. Start private, loosen only when you have a reason.

The constructor deserves special attention. C# lets you overload constructors — define multiple versions with different parameters — so callers can create objects with varying amounts of initial information. Use constructor chaining with this(...) to avoid duplicating validation logic across overloads.

ProductCatalogue.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
using System;

public class Product
{
    // Private backing fields — callers can't touch these directly
    private string _name;
    private decimal _price;
    private int _stockQuantity;

    // Full constructor: requires all information upfront
    public Product(string name, decimal price, int stockQuantity)
    {
        Name = name;           // Deliberately using the property setter here
        Price = price;         // so validation runs in ONE place
        StockQuantity = stockQuantity;
    }

    // Overloaded constructor: create a product with zero stock (e.g. pre-launch)
    // Chains to the main constructor — no duplicated validation!
    public Product(string name, decimal price) : this(name, price, 0)
    {
        Console.WriteLine($"'{name}' created with no initial stock.");
    }

    // Property with validation in the setter
    public string Name
    {
        get => _name;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("Product name cannot be blank.");
            _name = value.Trim();
        }
    }

    // Price can be read by anyone but only set privately (internal business logic)
    public decimal Price
    {
        get => _price;
        private set
        {
            if (value < 0)
                throw new ArgumentException($"Price cannot be negative. Got: {value}");
            _price = value;
        }
    }

    // Stock can be read publicly; set privately
    public int StockQuantity
    {
        get => _stockQuantity;
        private set
        {
            if (value < 0)
                throw new ArgumentException("Stock quantity cannot be negative.");
            _stockQuantity = value;
        }
    }

    // Computed (read-only) property — derived from other state, no backing field needed
    public bool IsInStock => _stockQuantity > 0;

    // Method that applies a discount — business logic lives HERE, not in calling code
    public void ApplyDiscount(decimal percentageOff)
    {
        if (percentageOff <= 0 || percentageOff >= 100)
            throw new ArgumentException("Discount must be between 1 and 99 percent.");

        decimal discountMultiplier = 1 - (percentageOff / 100m);
        Price = _price * discountMultiplier;  // Uses private setter — valid internal update
        Console.WriteLine($"Discount applied: {_name} is now {_price:C}");
    }

    public void AddStock(int units)
    {
        if (units <= 0)
            throw new ArgumentException("Units to add must be positive.");
        StockQuantity += units;
        Console.WriteLine($"Stock updated: {_name} now has {_stockQuantity} units.");
    }

    public override string ToString() =>
        $"{_name} | Price: {_price:C} | Stock: {_stockQuantity} | In Stock: {IsInStock}";
}

class Program
{
    static void Main()
    {
        // Using the overloaded constructor — no stock yet
        Product laptop = new Product("ProBook Laptop", 1299.99m);

        // Using the full constructor
        Product headphones = new Product("Studio Headphones", 249.99m, 50);

        laptop.AddStock(10);
        headphones.ApplyDiscount(15);

        Console.WriteLine();
        Console.WriteLine(laptop);
        Console.WriteLine(headphones);

        // This would cause a compile error — Price has a private setter:
        // laptop.Price = 999m;  // ERROR: 'Product.Price.set' is inaccessible
    }
}
▶ Output
'ProBook Laptop' created with no initial stock.
Stock updated: ProBook Laptop now has 10 units.
Discount applied: Studio Headphones is now £212.49

ProBook Laptop | Price: £1,299.99 | Stock: 10 | In Stock: True
Studio Headphones | Price: £212.49 | Stock: 50 | In Stock: True
⚠️
Watch Out: Auto-properties aren't always enoughAuto-properties like `public decimal Price { get; set; }` are fine for simple data containers (DTOs), but the moment you need validation, computed values or change notifications, you need a full property with a backing field. Don't let the convenience of auto-properties lull you into skipping validation that your business logic actually needs.

Static vs Instance Members — When the Object Doesn't Matter

Every member you've seen so far is an instance member — it belongs to a specific object. Sarah's balance is hers. James's balance is his. But sometimes data or behaviour belongs to the class itself, not to any one object. That's what static is for.

A static member is shared across all instances of the class and exists even before any objects are created. Think of it like a company-wide policy notice pinned to the wall — it applies to all employees (objects) regardless of which employee you're talking to.

Common real-world uses: a running count of how many instances have been created (audit trail), a factory method that controls how objects are born, a set of constants tied to the concept (like tax rates or conversion factors), or utility methods that operate on the type's data without needing a specific instance.

The golden rule: if a method doesn't read or write any instance fields (this.something), it's a candidate to be static. Making it static documents that intent clearly — it tells the next developer 'this method has no side effects on object state.'

EmployeeRegistry.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
using System;
using System.Collections.Generic;

public class Employee
{
    // Static field: shared by ALL Employee objects.
    // Counts every employee ever created — belongs to the class, not any one employee.
    private static int _totalEmployeesCreated = 0;

    // Static read-only: a company-wide policy value. Same for every employee.
    public static readonly decimal MinimumHourlyRate = 11.44m; // UK NMW 2024

    // Instance fields: unique to each employee object
    private readonly int _employeeId;
    private string _fullName;
    private decimal _hourlyRate;

    // Static constructor: runs ONCE when the class is first used, before any objects exist.
    // Useful for initialising heavy static resources (database connections, config loading).
    static Employee()
    {
        Console.WriteLine("[Employee class initialised — static constructor ran]");
    }

    public Employee(string fullName, decimal hourlyRate)
    {
        _totalEmployeesCreated++;          // Increments the class-level counter
        _employeeId = _totalEmployeesCreated; // Each employee gets the next ID

        FullName = fullName;
        HourlyRate = hourlyRate;
    }

    public string FullName
    {
        get => _fullName;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("Full name is required.");
            _fullName = value;
        }
    }

    public decimal HourlyRate
    {
        get => _hourlyRate;
        set
        {
            if (value < MinimumHourlyRate)
                throw new ArgumentException(
                    $"Hourly rate {value:C} is below the minimum wage of {MinimumHourlyRate:C}.");
            _hourlyRate = value;
        }
    }

    public int EmployeeId => _employeeId;

    // Static property: read the class-level counter from outside
    public static int TotalEmployeesCreated => _totalEmployeesCreated;

    // Static method: operates on the TYPE, not a specific instance.
    // Notice it has no access to _fullName or _hourlyRate — those are instance state.
    public static decimal CalculateWeeklyPay(decimal hourlyRate, int hoursWorked)
    {
        if (hoursWorked < 0 || hoursWorked > 168)
            throw new ArgumentException("Hours worked must be between 0 and 168 per week.");

        decimal overtime = hoursWorked > 40 ? (hoursWorked - 40) * hourlyRate * 1.5m : 0m;
        decimal regularPay = Math.Min(hoursWorked, 40) * hourlyRate;
        return regularPay + overtime;
    }

    // Instance method: uses THIS employee's specific hourly rate
    public decimal GetWeeklyPay(int hoursWorked)
    {
        // Delegates to the static version — no code duplication
        return CalculateWeeklyPay(_hourlyRate, hoursWorked);
    }

    public override string ToString() =>
        $"Employee #{_employeeId}: {_fullName} @ {_hourlyRate:C}/hr";
}

class Program
{
    static void Main()
    {
        // Static constructor fires here, the first time Employee is used
        Console.WriteLine($"Employees before hiring: {Employee.TotalEmployeesCreated}");
        Console.WriteLine($"Minimum wage: {Employee.MinimumHourlyRate:C}\n");

        Employee alice = new Employee("Alice Nguyen", 18.50m);
        Employee bob   = new Employee("Bob Okafor", 22.00m);
        Employee carol = new Employee("Carol Singh", 11.44m); // Exactly minimum wage — valid

        Console.WriteLine($"\nTotal employees hired: {Employee.TotalEmployeesCreated}");
        Console.WriteLine();

        // Instance method — uses Alice's specific rate
        Console.WriteLine($"{alice.FullName}'s pay for 45hrs: {alice.GetWeeklyPay(45):C}");

        // Static method — useful when you don't have an Employee object yet (e.g. salary negotiation UI)
        decimal negotiatedPay = Employee.CalculateWeeklyPay(25.00m, 40);
        Console.WriteLine($"Negotiated offer for 40hrs @ £25/hr: {negotiatedPay:C}");

        Console.WriteLine();
        Console.WriteLine(alice);
        Console.WriteLine(bob);
        Console.WriteLine(carol);

        // This would throw at runtime:
        // Employee underpaid = new Employee("Dave", 9.00m);
        // ArgumentException: Hourly rate £9.00 is below the minimum wage of £11.44.
    }
}
▶ Output
[Employee class initialised — static constructor ran]
Employees before hiring: 0
Minimum wage: £11.44

Total employees hired: 3

Alice Nguyen's pay for 45hrs: £879.25
Negotiated offer for 40hrs @ £25/hr: £1,000.00

Employee #1: Alice Nguyen @ £18.50/hr
Employee #2: Bob Okafor @ £22.00/hr
Employee #3: Carol Singh @ £11.44/hr
🔥
Interview Gold: Static vs SingletonInterviewers love asking the difference between a static class and a Singleton. A static class can't be instantiated at all and can't implement interfaces — it's just a bag of utility methods. A Singleton IS an object (one instance) that can implement interfaces, be passed as a dependency, and be swapped out in tests. Use static for pure utility; use Singleton when you need one shared stateful object that plays well with dependency injection.

Object References, Value Semantics and the new Keyword — Where Bugs Hide

This is where most intermediate developers have a moment of confusion that haunts them for months. In C#, a class is a reference type. When you write BankAccount copy = originalAccount; you haven't copied the account — you've created a second label pointing at the same object. Change the balance through copy and originalAccount will show the same change. They're the same cookie, not two cookies from the same cutter.

This is fundamentally different from value types (structs, int, decimal, bool) where assignment creates a true copy. The C# record type (introduced in C# 9) adds value-like copy semantics on top of class-like behaviour, but that's a separate topic.

Understanding this reference behaviour also explains why passing an object to a method and modifying it inside that method changes the caller's object — you passed the reference, not a clone. If you want to isolate changes, you need to explicitly clone the object.

The new keyword does three things: allocates memory on the managed heap, calls the constructor to initialise that memory, and returns a reference to it. When no references point to an object anymore, the Garbage Collector will eventually reclaim that memory — you don't manage it manually.

ReferenceSemantics.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
using System;

public class ShoppingCart
{
    public string CustomerName { get; set; }
    public decimal TotalValue  { get; set; }

    public ShoppingCart(string customerName, decimal totalValue)
    {
        CustomerName = customerName;
        TotalValue   = totalValue;
    }

    // Returns a genuine independent copy of this cart — a 'deep clone'
    public ShoppingCart Clone()
    {
        return new ShoppingCart(CustomerName, TotalValue);
    }

    public override string ToString() =>
        $"Cart[{CustomerName}, Total: {TotalValue:C}]";
}

class Program
{
    // This method receives a REFERENCE — modifying the cart here affects the caller's cart
    static void ApplyVIPDiscount(ShoppingCart cart)
    {
        cart.TotalValue *= 0.85m;  // 15% discount applied to the ORIGINAL object
        Console.WriteLine($"  Inside method: {cart}");
    }

    // This method receives a reference to a CLONED cart — caller's original is untouched
    static ShoppingCart PreviewVIPDiscount(ShoppingCart cart)
    {
        ShoppingCart preview = cart.Clone();  // Work on the copy
        preview.TotalValue  *= 0.85m;
        return preview;
    }

    static void Main()
    {
        ShoppingCart aliceCart = new ShoppingCart("Alice", 200m);

        // --- REFERENCE TRAP DEMO ---
        Console.WriteLine("=== Reference Assignment Trap ===");
        ShoppingCart aliasCart = aliceCart;  // NOT a copy — same object, two variable names

        aliasCart.TotalValue = 999m;  // This changes aliceCart too!
        Console.WriteLine($"aliceCart after aliasCart change: {aliceCart}");  // Shows 999!
        Console.WriteLine($"aliasCart:                        {aliasCart}");
        Console.WriteLine($"Same object? {ReferenceEquals(aliceCart, aliasCart)}");

        Console.WriteLine();

        // --- METHOD REFERENCE DEMO ---
        Console.WriteLine("=== Passing to Method — Reference Behaviour ===");
        ShoppingCart bobCart = new ShoppingCart("Bob", 300m);
        Console.WriteLine($"Before discount call: {bobCart}");
        ApplyVIPDiscount(bobCart);                    // Mutates Bob's cart inside the method
        Console.WriteLine($"After discount call:  {bobCart}");  // Already changed!

        Console.WriteLine();

        // --- CORRECT CLONE DEMO ---
        Console.WriteLine("=== Safe Preview Using Clone ===");
        ShoppingCart carolCart  = new ShoppingCart("Carol", 150m);
        ShoppingCart discounted = PreviewVIPDiscount(carolCart);
        Console.WriteLine($"Original carol cart:    {carolCart}");   // Unchanged
        Console.WriteLine($"Discounted preview cart: {discounted}"); // Clone with discount
        Console.WriteLine($"Same object? {ReferenceEquals(carolCart, discounted)}");
    }
}
▶ Output
=== Reference Assignment Trap ===
aliceCart after aliasCart change: Cart[Alice, Total: £999.00]
aliasCart: Cart[Alice, Total: £999.00]
Same object? True

=== Passing to Method — Reference Behaviour ===
Before discount call: Cart[Bob, Total: £300.00]
Inside method: Cart[Bob, Total: £255.00]
After discount call: Cart[Bob, Total: £255.00]

=== Safe Preview Using Clone ===
Original carol cart: Cart[Carol, Total: £150.00]
Discounted preview cart: Cart[Carol, Total: £127.50]
Same object? False
⚠️
Watch Out: The alias trap in loopsThe reference trap gets nastier in loops. If you do `cart = existingCart` inside a loop and then add `cart` to a list, every item in that list points to the same object. The fix is always `new` — create a fresh object per iteration, or use `.Clone()`. Forgetting this is one of the top sources of 'why do all my list items have the same value?' bugs.
AspectInstance MemberStatic Member
Belongs toA specific object (e.g. Sarah's account)The class itself (shared by all objects)
Accessed viaAn object reference: `alice.GetPay()`The class name: `Employee.CalculateWeeklyPay()`
MemoryAllocated per object on the heapAllocated once when class is first loaded
Can access instance fields?Yes — this is their whole purposeNo — there's no 'this' in a static context
LifecycleLives as long as the object is referencedLives for the entire duration of the app
Typical use casePer-object state and behaviourFactories, counters, utility methods, constants
Thread safety concern?Only if shared across threadsYes — static state is shared across all threads

🎯 Key Takeaways

  • A class is a blueprint; an object is a live instance — 'new' is the moment the blueprint becomes reality, allocating memory and running the constructor.
  • Encapsulation means your constructor validates, your setters enforce rules, and your fields are private — the object is always in a legal state because it won't allow itself to become illegal.
  • Assignment of a class variable copies the reference, not the object — two variables pointing at the same object is the most common source of 'ghost mutation' bugs in C#.
  • Static members belong to the class (shared, exist without any object) — use them for counters, constants, factories and pure utility methods; never for state that should vary per object.

⚠ Common Mistakes to Avoid

  • Mistake 1: Making fields public instead of using properties — Symptom: other classes set nonsensical values (negative age, null name) and the crash happens far from where the bad data entered. Fix: make fields private and expose them through properties with validation in the setter. The compiler enforces your rules so you don't have to hunt bugs.
  • Mistake 2: Confusing reference assignment with copying — Symptom: you 'copy' an object, change the copy, and your original mysteriously changes too. Running ReferenceEquals(a, b) returns True when you expected False. Fix: implement a Clone() method or a copy constructor public Product(Product source) that creates a genuinely new object with independent state.
  • Mistake 3: Putting business logic in the calling code instead of inside the class — Symptom: you see if (product.Price > 0) product.Price *= 0.9m; scattered in 6 different places across the codebase. When the discount rule changes, you have to find and update all 6. Fix: move logic into a method on the class (product.ApplyDiscount(10)) — one place to update, one place to test.

Interview Questions on This Topic

  • QWhat's the difference between a class and a struct in C#, and when would you deliberately choose one over the other?
  • QCan you explain what encapsulation means in practice — not the textbook definition, but how you actually apply it when designing a class?
  • QIf I pass an object to a method and modify it inside that method, does the caller's object change? What if I reassign the parameter entirely using 'new' — does that affect the caller?

Frequently Asked Questions

What is the difference between a class and an object in C#?

A class is the blueprint — it's code you write that defines fields, properties and methods. An object is a live instance of that blueprint created at runtime with the 'new' keyword. You write one class and can create thousands of independent objects from it, each with its own state.

Can a C# class have multiple constructors?

Yes — this is called constructor overloading. You define multiple constructors with different parameter lists and C# picks the right one based on what arguments you pass. Use constructor chaining with this(...) to call one constructor from another so you don't duplicate validation logic.

Why should I use properties instead of just making fields public in C#?

Public fields give callers unrestricted write access — nothing stops them setting a price to -£500 or a name to null. Properties let you add validation, computed values or change notifications in the getter/setter without changing the public API. It's the difference between handing someone your house key and letting them ring the doorbell — you stay in control of what happens.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousFile I/O in C#Next →Inheritance in C#
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged