Skip to content
Home C# / .NET C# Classes and Objects — Static Counter Invoice Collisions

C# Classes and Objects — Static Counter Invoice Collisions

Where developers are forged. · Structured learning · Free forever.
📍 Part of: OOP in C# → Topic 1 of 10
Static fields share state across all instances and threads, causing duplicate invoices under load.
⚙️ Intermediate — basic C# / .NET knowledge assumed
In this tutorial, you'll learn
Static fields share state across all instances and threads, causing duplicate invoices under load.
  • 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#.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • A class is a blueprint for creating objects; an object is the live instance in memory.
  • Encapsulation bundles data and behaviour; properties control access.
  • Static members belong to the class, not an instance; use them for shared state or utility.
  • Reference types store a pointer — assignment copies the reference, not the object.
  • Constructors enforce invariants; validate eagerly to avoid invalid-state bugs in production.
🚨 START HERE

Quick Debug Cheat Sheet: Object State Issues

Use these commands and checks when you suspect object identity or state problems.
🟡

Object changed unexpectedly after method call

Immediate ActionAdd `ReferenceEquals(log, original)` before and after the call to confirm same object.
Commands
`Console.WriteLine($"Same? {ReferenceEquals(myObj, original)}");`
Check method signature: parameter is class type? If yes, it receives a reference — modifications affect caller.
Fix NowIf you need isolation, clone the object before passing it: `var copy = original.Clone();`
🟡

Static field shows wrong value across requests

Immediate ActionCheck if the field is decorated with `[ThreadStatic]` or use `AsyncLocal` for per-request scoping.
Commands
Inspect AppDomain static: `typeof(MyClass).GetFields(BindingFlags.Static | BindingFlags.NonPublic)`
Place a lock around writes if thread safety is required: `lock (_lockObj) { counter++; }`
Fix NowFor most production cases, replace with a per-request DI-scoped service instead of static fields.
🟡

Auto-property setter not firing

Immediate ActionCheck if the property is auto-property: `{ get; set; }` — no custom logic runs.
Commands
Switch to full property with backing field if you need validation: `private int _x; public int X { get => _x; set { if(value < 0) throw ...; _x = value; } }`
In constructor, ensure you're assigning to the property, not the backing field.
Fix NowRun a quick search for `{ get; set; }` in the class and consider if any of them should have validation.
Production Incident

The Ghost Mutation: When a Static Counter Gave Customers the Same Invoice Number

A naive use of a static field caused invoice numbers to be shared across unrelated tenants, leaking financial data.
SymptomIn a multi-tenant SaaS billing system, invoices from different tenants had overlapping numbers. Customer A and Customer B received the same invoice number but different amounts. Payment reconciliation failed.
AssumptionThe developer assumed a static field would be reset per request because each HTTP request creates new objects.
Root causeA static field private static int _nextInvoiceNumber was used to generate sequential invoice IDs. Static fields are shared across all instances and all threads in the AppDomain. The field was never reset and was accessed concurrently without locking, leading to duplicates and race conditions.
FixReplace the static counter with a database sequence or Guid-based IDs. The static counter was removed and each tenant's invoice ID was derived from a tenant-scoped sequence table. Also added ConcurrentDictionary for tenant-specific counters if needed, but the final fix used database sequences with SELECT NEXT VALUE FOR.
Key Lesson
Static state is global by default — never assume it's scoped to a request or a tenant.Always validate assumptions about sharing: if data must be per-tenant or per-session, it must not be static.For production ID generation, prefer database sequences or GUIDs over in-memory counters.
Production Debug Guide

Symptom → Action guide for common class/object misbehaviour

Two variables supposed to hold different objects show the same values after assignmentCheck if you used reference assignment (var b = a;) instead of cloning. Add ReferenceEquals(a, b) to confirm they point to the same object. Implement a Clone() method that uses new and copies all fields.
A property setter throws unexpected validation errorsInspect the property setter logic — auto-properties have no validation. If you added validation, ensure the constructor uses the property (not the backing field) to run it. Add a breakpoint in the setter to see what value is being passed.
Static counter gives unexpected values or duplicates under loadUse Interlocked.Increment for thread-safe increments if a static counter is absolutely necessary. Better: replace with a database sequence or ConcurrentDictionary for per-key counters. Consider if the static field should be ThreadStatic or AsyncLocal.

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
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
using System;

public class BankAccount
{
    private string _accountHolder;
    private decimal _balance;

    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;
    }

    public string AccountHolder => _accountHolder;
    public decimal Balance => _balance;

    public void Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Deposit amount must be positive.");
        _balance += amount;
        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;
        }
        _balance -= amount;
        Console.WriteLine($"{_accountHolder} withdrew {amount:C}. New balance: {_balance:C}");
        return true;
    }

    public override string ToString()
    {
        return $"Account[{_accountHolder}, Balance: {_balance:C}]";
    }
}

class Program
{
    static void Main()
    {
        BankAccount sarahAccount = new BankAccount("Sarah", 1000m);
        BankAccount jamesAccount = new BankAccount("James", 500m);

        sarahAccount.Deposit(250m);
        sarahAccount.Withdraw(100m);
        jamesAccount.Withdraw(600m);

        Console.WriteLine();
        Console.WriteLine(sarahAccount);
        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 constructor
Your 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.
📊 Production Insight
Missing validation in constructors is the #1 cause of 'impossible' null reference exceptions in production.
A single invalid object can corrupt downstream caches, reports, and external integrations.
Rule: every constructor must enforce at least the null check on required fields and range checks on numeric fields.
🎯 Key Takeaway
A class is a contract: every instance must be valid upon creation.
Validation in the constructor is free insurance against hours of debugging.
The constructor's job is to make impossible states impossible.

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
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
using System;

public class Product
{
    private string _name;
    private decimal _price;
    private int _stockQuantity;

    public Product(string name, decimal price, int stockQuantity)
    {
        Name = name;
        Price = price;
        StockQuantity = stockQuantity;
    }

    public Product(string name, decimal price) : this(name, price, 0)
    {
        Console.WriteLine($"'{name}' created with no initial stock.");
    }

    public string Name
    {
        get => _name;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("Product name cannot be blank.");
            _name = value.Trim();
        }
    }

    public decimal Price
    {
        get => _price;
        private set
        {
            if (value < 0)
                throw new ArgumentException($"Price cannot be negative. Got: {value}");
            _price = value;
        }
    }

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

    public bool IsInStock => _stockQuantity > 0;

    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;
        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()
    {
        Product laptop = new Product("ProBook Laptop", 1299.99m);
        Product headphones = new Product("Studio Headphones", 249.99m, 50);

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

        Console.WriteLine();
        Console.WriteLine(laptop);
        Console.WriteLine(headphones);
    }
}
▶ 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 enough
Auto-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.
📊 Production Insight
Public auto-properties are the leading cause of data corruption in production.
Without validation in the setter, any code can set Price to -1, breaking downstream calculations.
Rule: if a property needs business rules, use a full property with a private setter and validate in the public setter.
🎯 Key Takeaway
Start with private fields, expose only through properties with validation.
Constructor chaining prevents duplication of validation logic.
Encapsulation is about ensuring your object can never be in an invalid state.

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
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
using System;

public class Employee
{
    private static int _totalEmployeesCreated = 0;
    public static readonly decimal MinimumHourlyRate = 11.44m;

    private readonly int _employeeId;
    private string _fullName;
    private decimal _hourlyRate;

    static Employee()
    {
        Console.WriteLine("[Employee class initialised — static constructor ran]");
    }

    public Employee(string fullName, decimal hourlyRate)
    {
        _totalEmployeesCreated++;
        _employeeId = _totalEmployeesCreated;
        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;
    public static int TotalEmployeesCreated => _totalEmployeesCreated;

    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;
    }

    public decimal GetWeeklyPay(int hoursWorked)
    {
        return CalculateWeeklyPay(_hourlyRate, hoursWorked);
    }

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

class Program
{
    static void Main()
    {
        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);

        Console.WriteLine($"Total employees hired: {Employee.TotalEmployeesCreated}");
        Console.WriteLine();
        Console.WriteLine($"{alice.FullName}'s pay for 45hrs: {alice.GetWeeklyPay(45):C}");
        Console.WriteLine($"Negotiated offer for 40hrs @ £25/hr: {Employee.CalculateWeeklyPay(25.00m, 40):C}");
        Console.WriteLine();
        Console.WriteLine(alice);
        Console.WriteLine(bob);
        Console.WriteLine(carol);
    }
}
▶ Output
[Employee class initialised — static constructor ran]
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 Singleton
Interviewers 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.
📊 Production Insight
Static state is global and lives forever — it's the #1 cause of memory leaks in long-running services.
Concurrent modifications to static fields without synchronization cause data races.
Rule: avoid mutable static fields. If you must, use ConcurrentDictionary or Interlocked and document the thread safety contract.
🎯 Key Takeaway
Static members are shared across all instances and all threads.
They're great for utility methods and constants.
But mutable static state? That's a production incident waiting to happen.

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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
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;
    }

    public ShoppingCart Clone()
    {
        return new ShoppingCart(CustomerName, TotalValue);
    }

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

class Program
{
    static void ApplyVIPDiscount(ShoppingCart cart)
    {
        cart.TotalValue *= 0.85m;
        Console.WriteLine($"  Inside method: {cart}");
    }

    static ShoppingCart PreviewVIPDiscount(ShoppingCart cart)
    {
        ShoppingCart preview = cart.Clone();
        preview.TotalValue  *= 0.85m;
        return preview;
    }

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

        Console.WriteLine("=== Reference Assignment Trap ===");
        ShoppingCart aliasCart = aliceCart;
        aliasCart.TotalValue = 999m;
        Console.WriteLine($"aliceCart after aliasCart change: {aliceCart}");
        Console.WriteLine($"Same object? {ReferenceEquals(aliceCart, aliasCart)}");

        Console.WriteLine();
        Console.WriteLine("=== Passing to Method — Reference Behaviour ===");
        ShoppingCart bobCart = new ShoppingCart("Bob", 300m);
        Console.WriteLine($"Before discount call: {bobCart}");
        ApplyVIPDiscount(bobCart);
        Console.WriteLine($"After discount call:  {bobCart}");

        Console.WriteLine();
        Console.WriteLine("=== Safe Preview Using Clone ===");
        ShoppingCart carolCart  = new ShoppingCart("Carol", 150m);
        ShoppingCart discounted = PreviewVIPDiscount(carolCart);
        Console.WriteLine($"Original carol cart:    {carolCart}");
        Console.WriteLine($"Discounted preview cart: {discounted}");
        Console.WriteLine($"Same object? {ReferenceEquals(carolCart, discounted)}");
    }
}
▶ Output
=== Reference Assignment Trap ===
aliceCart after aliasCart change: 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 loops
The 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.
📊 Production Insight
Reference aliasing is the #1 cause of 'spooky action at a distance' bugs in production.
Imperative clones missed in hot paths lead to corrupted state that's hard to trace.
Rule: if you pass an object to a method and do not intend the method to mutate it, clone defensively.
🎯 Key Takeaway
Assignment of a class variable copies the reference, not the object.
Always clone when you need an independent copy.
new allocates, constructs, and returns a reference — never forget the three steps.

Class Design Best Practices for Production Applications

You've learned the mechanics of classes — now let's talk about what separates production-grade code from unit-test-time-only code. These aren't theoretical; they're patterns enforced in codebases that survive years of change.

  1. Single Responsibility Principle (SRP): A class should have one reason to change. If your class both calculates totals AND sends emails, split it. SRP makes classes testable, understandable, and replaceable.
  2. Favor composition over inheritance: Inheritance creates tight coupling. A change in the base class can break all derived classes. Composition using interfaces lets you swap behaviors without breaking existing code.
  3. Immutable by default: Make fields readonly, use get-only properties, and avoid exposing setters unless mutation is explicitly required. Immutable objects are inherently thread-safe and easier to reason about.
  4. Minimal public surface: Only expose what absolutely needs to be public. Every public member is a contract you must maintain. Use internal for intra-assembly sharing, keep everything else private.
  5. Implement IDisposable for unmanaged resources: If your class holds file handles, database connections, or sockets, implement IDisposable and follow the dispose pattern. Use constructors that throw on invalid state — don't let half-initialized objects exist.
ProductionClass.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
using System;

// Example of SRP and immutable by default
public class OrderLine
{
    public string ProductId { get; }
    public int Quantity { get; }
    public decimal UnitPrice { get; }

    public OrderLine(string productId, int quantity, decimal unitPrice)
    {
        if (string.IsNullOrWhiteSpace(productId))
            throw new ArgumentException("ProductId required");
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive");
        if (unitPrice < 0)
            throw new ArgumentException("UnitPrice cannot be negative");

        ProductId = productId;
        Quantity = quantity;
        UnitPrice = unitPrice;
    }

    public decimal LineTotal => Quantity * UnitPrice;
}

// Composition over inheritance: use interfaces
public interface ITaxCalculator
{
    decimal CalculateTax(OrderLine line);
}

public class StandardTaxCalculator : ITaxCalculator
{
    public decimal CalculateTax(OrderLine line) => line.LineTotal * 0.20m; // 20% VAT
}

public class OrderService
{
    private readonly ITaxCalculator _taxCalc;

    public OrderService(ITaxCalculator taxCalc)
    {
        _taxCalc = taxCalc ?? throw new ArgumentNullException(nameof(taxCalc));
    }

    public decimal CalculateOrderTotal(OrderLine[] lines)
    {
        decimal total = 0;
        foreach (var line in lines)
        {
            total += line.LineTotal + _taxCalc.CalculateTax(line);
        }
        return total;
    }
}

class Program
{
    static void Main()
    {
        var line = new OrderLine("PRD-001", 2, 49.99m);
        Console.WriteLine($"Line total: {line.LineTotal:C}");

        var service = new OrderService(new StandardTaxCalculator());
        var total = service.CalculateOrderTotal(new[] { line });
        Console.WriteLine($"Order total with tax: {total:C}");
    }
}
▶ Output
Line total: £99.98
Order total with tax: £119.98
Mental Model
Mental Model: Class as a Transaction Boundary
Think of a class as a mini-database transaction: it starts in a valid state, performs operations that keep it valid, and never exposes invalid intermediate state.
  • The constructor is the BEGIN TRANSACTION — it sets up the invariants.
  • Methods are operations that mutate state while preserving invariants.
  • Properties are the COMMIT — they expose state only when it's ready.
  • A well-designed class never requires the caller to guess about its internal state.
📊 Production Insight
Teams that skip class design best practices spend 3x more hours debugging mysterious state corruption.
The most expensive bug is the one that could have been prevented by a private setter.
Rule: design classes as if the next developer who touches them is a psychopath who knows where you live.
🎯 Key Takeaway
Favor immutability and composition.
Minimize public surface — every setter is a liability.
A class that's easy to test is a class that works in production.
🗂 Instance vs Static Members
Key differences every C# developer must know
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.
  • Production class design prioritizes immutability, small interfaces, and composition over inheritance — every public member is a permanent contract.

⚠ Common Mistakes to Avoid

    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. The original source of the bad value is hard to trace.

    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 manually.

    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. This leads to data corruption in reports, workflows, and cached state.

    Fix

    Implement a Clone() method or a copy constructor public Product(Product source) that creates a genuinely new object with independent state. Use new for each copy.

    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. This leads to inconsistencies and missed updates.

    Fix

    Move logic into a method on the class (product.ApplyDiscount(10)) — one place to update, one place to test. The class should be the single source of truth for its own behavior.

    Using auto-properties when validation is needed
    Symptom

    A property like public decimal Price { get; set; } allows any value, including negative or absurdly high numbers. Downstream calculations produce nonsense without throwing errors, making debugging extremely difficult.

    Fix

    Switch to a full property with a backing field and private setter. Validate in the setter. Use auto-properties only for DTOs or simple data transfer objects where no business rules apply.

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?Mid-levelReveal
    A class is a reference type stored on the heap; assignment copies the reference. A struct is a value type stored on the stack or inline; assignment copies the value. Use structs for small, immutable data containers (like a Point or Money) where you want value semantics and no heap allocation. Use classes for larger objects, polymorphic behavior, or when you need inheritance. The rule of thumb: use a class unless you have a specific reason for a struct.
  • QCan you explain what encapsulation means in practice — not the textbook definition, but how you actually apply it when designing a class?SeniorReveal
    Encapsulation means I decide what the outside world can see and touch. In practice, I start by making all fields private. Then I expose properties with getters for read access and, only when necessary, setters with validation. Business logic stays inside the class as methods — the class knows how to validate its own state. For example, instead of having external code calculate total price with tax, I add a CalculateTotalWithTax() method. This way, when tax rules change, I change one method, not fifty call sites.
  • 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?Mid-levelReveal
    Yes, modifying the object's properties inside the method affects the caller because the parameter holds a reference to the same heap object. However, if you reassign the parameter with new inside the method (e.g., param = new MyClass()), that only changes the local variable — the caller still holds the reference to the original object. This is a common confusion: passing a reference type by value means the reference itself is copied, but both copies point to the same object. Mutations through either reference are visible to both.
  • QHow would you design a thread-safe counter class in C#?SeniorReveal
    Use Interlocked.Increment for simple counters. For example: private int _count; public int Increment() => Interlocked.Increment(ref _count);. If you need per-key counters, use ConcurrentDictionary. Avoid lock unless you also need to protect multiple operations atomically. For a call-counting use case, a static field with a lock would work but is slower. The key insight: static state shared across threads must always be synchronized.

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.

When should I use static methods instead of instance methods?

Static methods are appropriate when the method doesn't depend on any instance state — it only uses its parameters and possibly static fields. Examples: utility functions, factory methods, or operations that don't need 'this'. If the method reads or writes any instance field, it must be an instance method.

What is the difference between a shallow copy and a deep copy?

A shallow copy copies the reference of each field — if a field is a reference type, both original and copy share the same referenced object. A deep copy recursively creates independent copies of all referenced objects. In C#, MemberwiseClone() creates a shallow copy. For deep copies, you typically implement a custom Clone() method using new for each nested object.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

Next →Inheritance in C#
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged