C# Classes and Objects — Static Counter Invoice Collisions
- 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#.
- 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.
Quick Debug Cheat Sheet: Object State Issues
Object changed unexpectedly after method call
`Console.WriteLine($"Same? {ReferenceEquals(myObj, original)}");`Check method signature: parameter is class type? If yes, it receives a reference — modifications affect caller.Static field shows wrong value across requests
Inspect AppDomain static: `typeof(MyClass).GetFields(BindingFlags.Static | BindingFlags.NonPublic)`Place a lock around writes if thread safety is required: `lock (_lockObj) { counter++; }`Auto-property setter not firing
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.Production Incident
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.ConcurrentDictionary for tenant-specific counters if needed, but the final fix used database sequences with SELECT NEXT VALUE FOR.Production Debug GuideSymptom → Action guide for common class/object misbehaviour
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.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.
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); } }
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]
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.
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); } }
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
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.'
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); } }
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
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.
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)}"); } }
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
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.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.
- 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.
- 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.
- 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. - Minimal public surface: Only expose what absolutely needs to be public. Every public member is a contract you must maintain. Use
internalfor intra-assembly sharing, keep everything else private. - Implement
IDisposablefor unmanaged resources: If your class holds file handles, database connections, or sockets, implementIDisposableand follow the dispose pattern. Use constructors that throw on invalid state — don't let half-initialized objects exist.
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}"); } }
Order total with tax: £119.98
- 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.
| Aspect | Instance Member | Static Member |
|---|---|---|
| Belongs to | A specific object (e.g. Sarah's account) | The class itself (shared by all objects) |
| Accessed via | An object reference: alice. | The class name: Employee. |
| Memory | Allocated per object on the heap | Allocated once when class is first loaded |
| Can access instance fields? | Yes — this is their whole purpose | No — there's no 'this' in a static context |
| Lifecycle | Lives as long as the object is referenced | Lives for the entire duration of the app |
| Typical use case | Per-object state and behaviour | Factories, counters, utility methods, constants |
| Thread safety concern? | Only if shared across threads | Yes — 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
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
- QCan you explain what encapsulation means in practice — not the textbook definition, but how you actually apply it when designing a class?SeniorReveal
- 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
- QHow would you design a thread-safe counter class in C#?SeniorReveal
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.
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.