Senior 11 min · March 06, 2026

C# Classes and Objects — Static Counter Invoice Collisions

Static fields share state across all instances and threads, causing duplicate invoices under load.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
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.
✦ Definition~90s read
What is Classes and Objects in C#?

C# classes are reference-type blueprints that define the structure and behavior of objects in memory. When you declare a class, you're telling the CLR how to allocate heap memory for instances, what data they carry (fields/properties), and what operations they support (methods/events).

Think of a class like a cookie cutter.

The new keyword triggers a two-phase construction: memory allocation on the managed heap, then constructor execution to initialize state. Without classes, you'd be writing procedural code with loose data structures—classes enforce encapsulation, inheritance, and polymorphism, which are non-negotiable for any non-trivial .NET application.

Static members exist at the type level, not the instance level. A static counter on an Invoice class increments across all instances, which is exactly where collisions happen in multi-threaded scenarios—two threads calling new Invoice() simultaneously can read the same counter value before either writes, producing duplicate invoice numbers.

This is why production code uses Interlocked.Increment or database sequences for identity generation. Instance members, by contrast, are per-object: each Invoice has its own TotalAmount and LineItems. The distinction matters because static state is global state, and global state is the enemy of testability and thread safety.

Object references in C# are pointers to heap memory, not the objects themselves. When you write Invoice a = new Invoice(); Invoice b = a;, both variables point to the same heap object—mutating a.Total also changes b.Total. This reference semantics is the root of countless bugs when developers pass objects to methods expecting value-like isolation.

Value types (struct) avoid this but introduce copy overhead and boxing penalties. The new keyword for reference types always allocates on the heap; for value types it just calls the constructor on the stack. Understanding this allocation model is critical for performance-sensitive code—allocating 10,000 small objects in a tight loop will trigger GC pressure, while a single array of structs won't.

In production, class design follows SOLID principles: single responsibility (one reason to change), open-closed (extend without modifying), Liskov substitution (derived classes must be substitutable for base), interface segregation (small focused contracts), and dependency inversion (depend on abstractions, not concretions). Avoid public fields—use auto-properties with private setters.

Make classes sealed unless you've designed for inheritance. Use readonly fields for immutable state. The parser doesn't care about your feelings—it enforces access modifiers (public, private, internal, protected) at compile time, but runtime reflection can bypass them.

If you need true immutability, use record types (C# 9+) which give you value equality and nondestructive mutation out of the box.

Plain-English First

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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.
C# Static Counter Invoice Collisions THECODEFORGE.IO C# Static Counter Invoice Collisions Class design and static vs instance member pitfalls Class Declaration Blueprint for invoice objects Constructor & Properties Initialize and encapsulate data Static Counter Member Shared across all instances Instance Invoice Data Unique per object Collision Risk Static counter increments incorrectly Thread-Safe Counter Use lock or Interlocked.Increment ⚠ Static counters cause race conditions in multithreaded code Always synchronize static state with locks or atomic operations THECODEFORGE.IO
thecodeforge.io
C# Static Counter Invoice Collisions
Classes Objects Csharp

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
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: Class as a Transaction Boundary
  • 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.

Declaration of a Class — The Parser Doesn't Care About Your Feelings

A class declaration is nothing more than a contract with the compiler. You're telling it: here’s a new type, here’s what it contains, here’s how you create one. The syntax is minimal, but the implications are massive.

The class keyword, a name, a body in curly braces. That’s it. But inside that body you define the shape of every object that will ever exist from this blueprint. Fields, properties, methods, events — these are the members. Get the structure wrong here and you’ll be refactoring a disaster six months in.

Start with the simplest possible declaration that compiles. Then add behavior. Production code doesn’t need a Person class with forty properties on day one. It needs an Invoice that can calculate its total. Build from there.

Access modifiers matter at declaration time. public means anyone can see it. private means only this class. internal means only this assembly. If you default to public you’re asking for coupling pain. Default to private and expose only what’s necessary.

InvoiceDeclaration.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// io.thecodeforge — csharp tutorial

public class Invoice
{
    private readonly List<LineItem> _lineItems = new();
    private decimal _taxRate;

    public Invoice(int id, decimal taxRate)
    {
        Id = id;
        _taxRate = taxRate;
    }

    public int Id { get; }

    public void AddItem(string sku, decimal unitPrice, int quantity)
    {
        _lineItems.Add(new LineItem(sku, unitPrice, quantity));
    }

    public decimal CalculateTotal()
    {
        decimal subtotal = 0;
        foreach (var item in _lineItems)
        {
            subtotal += item.TotalPrice;
        }
        return subtotal * (1 + _taxRate);
    }

    private record LineItem(string Sku, decimal UnitPrice, int Quantity)
    {
        public decimal TotalPrice => UnitPrice * Quantity;
    }
}

// Usage
var invoice = new Invoice(1001, 0.08m);
invoice.AddItem("SKU-001", 49.99m, 2);
invoice.AddItem("SKU-002", 19.99m, 1);
Console.WriteLine($"Invoice Total: {invoice.CalculateTotal():C}");
Output
Invoice Total: $129.57
Senior Shortcut:
Use record for simple data carriers like LineItem. You get value equality, immutability, and reduced boilerplate. Don’t write a full class for every little thing.
Key Takeaway
A class declaration defines a type. Keep it small. Expose only what callers absolutely need.

Syntax Isn't Academic — It's Your Safety Net

C# syntax around classes isn’t just ceremony. Every keyword, every brace, every semicolon is either enforcing a rule or preventing a mistake. When junior devs complain about verbosity, they’re missing the point: the compiler is catching bugs before they hit production.

Take the new keyword. It’s not optional. It’s explicitly allocating memory and invoking a constructor. Without it, you have a null reference waiting to happen. Take readonly on fields — it guarantees that once set, that value doesn’t change. That’s not style. That’s a concurrency safety net.

Properties with { get; set; } are not just fancy public fields. They’re methods in disguise. You can add validation, logging, or lazy initialization later without breaking callers. Start with auto-properties, but never assume they’ll stay simple.

Constructors chain with : this() or : base(). If you’re duplicating initialization logic, you’re doing it wrong. Use constructor chaining to keep your code DRY and avoid subtle bugs where one path forgets to set a field.

OrderSyntaxSnap.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// io.thecodeforge — csharp tutorial

public class Order
{
    private readonly List<string> _items = new();
    private readonly DateTime _createdAt;

    // Constructor chaining prevents duplication
    public Order(int orderId) : this(orderId, DateTime.UtcNow)
    {
    }

    public Order(int orderId, DateTime createdAt)
    {
        if (orderId <= 0)
            throw new ArgumentException("Order ID must be positive", nameof(orderId));

        OrderId = orderId;
        _createdAt = createdAt;
    }

    public int OrderId { get; }
    
    public string Status { get; private set; } = "Pending";

    public void AddItem(string item)
    {
        if (string.IsNullOrWhiteSpace(item))
            throw new ArgumentException("Item cannot be empty", nameof(item));
        _items.Add(item);
    }

    public int ItemCount => _items.Count;
}

// Usage
var order = new Order(5001);
Console.WriteLine($"Order {order.OrderId} created — Status: {order.Status}, Items: {order.ItemCount}");
Output
Order 5001 created — Status: Pending, Items: 0
Production Trap:
Never use public setters on collections. That allows external code to replace the entire list, bypassing any validation or tracking you built. Expose AddItem() or return IReadOnlyList<T>.
Key Takeaway
C# syntax exists to enforce discipline. Embrace it. Every keyword is a constraint that prevents a class of bugs.

Indexers — When Your Object Should Behave Like an Array

You've got a collection inside a class. Maybe it's a list of log entries, a lookup table, or a custom cache. You could expose that collection with a getter, but then you're leaking internal state and coupling callers to the underlying type. That's where indexers come in.

An indexer lets you use bracket notation directly on your object. Instead of myLogs.GetEntry(42), you write myLogs[42]. It's syntactic sugar, but powerful sugar. You define it like a property with this[int index], and you control both get and set logic, validation, and read-only access.

The why: encapsulation without sacrificing ergonomics. The how: public LogEntry this[int index] { get { ... } set { ... } }. One gotcha — you can overload indexers by parameter type, but be careful; too many overloads confuse consumers.

IndexerExample.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// io.thecodeforge — csharp tutorial

public class LogBuffer
{
    private LogEntry[] _entries = new LogEntry[100];
    private int _count;

    public LogEntry this[int index]
    {
        get
        {
            if (index < 0 || index >= _count)
                throw new IndexOutOfRangeException();
            return _entries[index];
        }
        set
        {
            if (index < 0 || index >= _entries.Length)
                throw new IndexOutOfRangeException();
            _entries[index] = value;
            if (index >= _count) _count = index + 1;
        }
    }

    public int Count => _count;
}

// Usage
var buffer = new LogBuffer();
buffer[0] = new LogEntry("Startup complete");
Console.WriteLine(buffer[0].Message);
Output
Startup complete
Production Trap:
Indexers don't participate in interfaces like IEnumerable or IList. If you need LINQ, implement IEnumerable<T> separately. Indexer alone won't give you foreach or .Where().
Key Takeaway
Use indexers when your class 'is' a collection, not when it 'has' a collection.

Sorting Complex Lists with Comparison — No IComparer Required

The List<T>.Sort() overload that takes a Comparison<T> delegate is the fastest path to custom sorting when you don't want to write a whole comparer class. Comparison<T> is just a delegate: int(T x, T y). Return negative if x before y, positive if x after y, zero if equal.

The why: It's inline. No separate file, no interface implementation, no boilerplate. You write the comparison logic exactly where the sorting happens — readable, testable, disposable. The how: list.Sort((a, b) => a.Priority.CompareTo(b.Priority)).

Watch out for unstable sorts and overflow in int returns. For descending, swap the comparison: b.Priority.CompareTo(a.Priority). For multi-key sorts, chain with the null-conditional operator or a conditional expression. Production code uses this constantly for in-memory reporting, UI reordering, and batch processing where performance isn't the bottleneck.

SortWithDelegate.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge — csharp tutorial

public class LogEntry
{
    public string Message { get; set; }
    public DateTime Timestamp { get; set; }
    public int Severity { get; set; }
}

var logs = new List<LogEntry>
{
    new LogEntry { Message = "Warn", Severity = 2, Timestamp = DateTime.Now },
    new LogEntry { Message = "Error", Severity = 3, Timestamp = DateTime.Now.AddHours(-1) },
    new LogEntry { Message = "Info", Severity = 1, Timestamp = DateTime.Now.AddDays(-1) }
};

logs.Sort((a, b) =>
{
    int result = b.Severity.CompareTo(a.Severity); // descending severity
    if (result == 0)
        result = a.Timestamp.CompareTo(b.Timestamp); // ascending time
    return result;
});

foreach (var log in logs)
    Console.WriteLine($"{log.Severity}: {log.Message} ({log.Timestamp:d})");
Output
3: Error (1/25/2025)
2: Warn (1/26/2025)
1: Info (1/25/2025)
Senior Shortcut:
For simple single-property sorts, use list.OrderBy(x => x.Property).ToList() if you need a new list. Use Comparison<T> when sorting in-place to avoid allocation overhead from LINQ.
Key Takeaway
List.Sort with a Comparison<T> delegate is the shortest path to custom in-place sorting — no IComparer, no boilerplate.

Parameter Binding in ASP.NET WebAPI — Stop Guessing Where Your Data Goes

When a request hits your API controller, WebAPI has to map HTTP data — query string, route, body, headers — to your action method's parameters. That mapping is parameter binding. Know it or your endpoints will silently fail with nulls or 400s.

The why: You control exactly where each value comes from. No implicit magic, no silent fallbacks. The how: [FromUri] for query parameters, [FromBody] for JSON/XML payloads, [FromRoute] for route data, [FromHeader] for headers. By default, simple types come from URI, complex types from body. That default is a trap when you mix them.

Production rule: Be explicit. If a parameter comes from the query string, slap [FromUri] on it. If it's the POST body, [FromBody]. One [FromBody] per action maximum — WebAPI reads the body once. Multiple [FromBody] parameters will fail. Use a wrapper DTO instead. Always validate binding with model state on the first line of your action.

ParameterBindingController.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// io.thecodeforge — csharp tutorial

[ApiController]
[Route("api/[controller]")]
public class LogsController : ControllerBase
{
    // GET /api/logs?severity=3&page=1
    [HttpGet]
    public IActionResult GetLogs(
        [FromUri] int severity,
        [FromUri] int page = 1)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);
        return Ok($"Fetching severity {severity}, page {page}");
    }

    // POST /api/logs  with JSON body: { "message": "foo", "severity": 2 }
    [HttpPost]
    public IActionResult CreateLog([FromBody] LogEntry entry)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);
        return Created("/api/logs/1", entry);
    }
}

public class LogEntry
{
    public string Message { get; set; }
    public int Severity { get; set; }
}
Output
GET /api/logs?severity=3 -> "Fetching severity 3, page 1"
POST /api/logs {"message":"test","severity":2} -> 201 Created
Production Trap:
Never put [FromBody] on more than one parameter. WebAPI reads the request stream once. Second [FromBody] will be null. Group related data into a single DTO.
Key Takeaway
Explicitly decorate every parameter. [FromBody] is one per action. Use model state check immediately. Your future self will thank you.

C# Destructors — When Objects Die, Someone Has to Clean Up

Destructors exist only for unmanaged resource cleanup—handles, file streams, database connections. Unlike constructors, you never call a destructor directly; the garbage collector invokes it before reclaiming memory. The key rule: if your class holds unmanaged resources, implement the IDisposable pattern with a finalizer. Destructors add performance overhead because objects with finalizers survive an extra GC generation. Production rule: never rely on destructor timing—you can't predict when it runs. The standard pattern is Dispose() for deterministic cleanup, plus a destructor as safety net. Destructors cannot have access modifiers, cannot take parameters, and a class can only have one. They're syntactic sugar for Finalize(). In modern C#, SafeHandle or wrapper classes mitigate the need. Use destructors sparingly—they're the emergency brake, not the parking brake.

DestructorExample.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — csharp tutorial

public class FileHandler
{
    private IntPtr handle;

    public FileHandler(string path)
    {
        handle = OpenFile(path);
    }

    ~FileHandler()
    {
        if (handle != IntPtr.Zero)
            CloseHandle(handle);
    }

    private IntPtr OpenFile(string path) => IntPtr.Zero;
    private void CloseHandle(IntPtr h) { }
}
Output
No output — destructors run nondeterministically during GC
Production Trap:
If your class has a destructor but no Dispose() method, unmanaged resources may leak when consumers forget to call Dispose. Always pair finalizers with IDisposable.
Key Takeaway
Destructors are for unmanaged resource safety nets—never for normal cleanup.

Challenge — Log All Transactions to a Private Audit Trail

A bank account class needs every deposit and withdrawal recorded. This challenge forces you to combine encapsulation, collections, and immutability. Write a class BankAccount with a private List<Transaction> log. Each Transaction should contain Amount, Timestamp, and Type (Deposit/Withdrawal). Methods Deposit(decimal) and Withdraw(decimal) validate the amount (positive, sufficient balance), add to balance, then append to log. Add a property IReadOnlyList<Transaction> AuditLog returning the log as read-only to prevent external tampering. Then challenge: ensure thread safety—use lock statements so concurrent calls don't corrupt the log. Test by starting two threads making 1000 random operations each. Verify final balance matches sum of transactions. This exercise teaches defensive copying, immutability via IReadOnlyList, and real-world concurrency patterns in banking contexts.

BankAccountChallenge.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — csharp tutorial

public class BankAccount
{
    private decimal balance;
    private readonly List<Transaction> log = new();
    private readonly object padlock = new();

    public void Deposit(decimal amount)
    {
        lock (padlock)
        {
            balance += amount;
            log.Add(new(amount, DateTime.UtcNow, "Deposit"));
        }
    }

    public IReadOnlyList<Transaction> AuditLog =>
        log.AsReadOnly();
}

public record Transaction(decimal Amount, DateTime Timestamp, string Type);
Output
Compiles — run with two threads to verify log integrity
Production Trap:
Returning the raw List from AuditLog exposes your internal state to mutation. Always wrap with AsReadOnly() or return an immutable copy.
Key Takeaway
Audit logs require immutable views and thread-safe writes—never expose mutable internals.

Before You Start — Prerequisites & Setup

Before writing a single line of C# class code, you need the right tooling. This guide assumes you have Visual Studio 2022+ (any edition) or .NET SDK 8.0+ installed with a terminal. For the code examples, create a new Console App: dotnet new console -n BankApp. The simplest prerequisite is familiarity with basic C# syntax (variables, methods, if statements). No prior knowledge of OOP is required — we'll build objects from scratch. The actual installation involves downloading the .NET SDK from dotnet.microsoft.com, running the installer, and verifying with dotnet --version in your terminal. If you're using Visual Studio, select the '.NET desktop development' workload during installation. For VS Code, install the C# extension by Microsoft. Once installed, navigate to your project folder and run dotnet restore to resolve dependencies. This setup ensures your compiler flags type mismatches early, preventing runtime surprises when dealing with class instances and their methods.

SetupCheck.csCSHARP
1
2
3
4
5
6
7
8
9
// io.thecodeforge — csharp tutorial
// Verify .NET SDK installation
dotnet --version
// Expected output: 8.0.100 or higher

// Create project
dotnet new console -n BankApp
cd BankApp
dotnet restore
Output
8.0.100
Restored /BankApp/BankApp.csproj (in 85 ms).
Production Trap:
Skipping the SDK version check is a common cause of missing features—C# 11 class improvements (like required members) won't compile on older runtimes.
Key Takeaway
Always verify your .NET SDK version before writing classes to avoid missing language features.

Create Deposits and Withdrawals — Output to Terminal

Now we implement a BankAccount class that models real-world transactions. The class holds a private _balance field. A Deposit method adds money after validating non-negative amounts; a Withdraw method subtracts money only if sufficient funds exist (preventing overdrafts). Both methods return the new balance. The Main method creates an instance using new, performs three operations (deposit 100, withdraw 30, withdraw 80 to trigger failure), and outputs each result with Console.WriteLine. The output shows the balance after each successful transaction and a rejection message for the failed withdrawal. This pattern — encapsulating validation inside the class — prevents callers from accidentally corrupting state. The key insight: by making balance private and exposing controlled methods, you enforce business rules at the compiler level. No external code can directly set _balance = -500, stopping bugs before they reach production.

BankAccount.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge — csharp tutorial
BankAccount account = new();
Console.WriteLine(account.Deposit(100));   // 100
Console.WriteLine(account.Withdraw(30));    // 70
Console.WriteLine(account.Withdraw(80));    // Insufficient

class BankAccount
{
    private decimal _balance;
    
    public decimal Deposit(decimal amount)
    {
        if (amount <= 0) return _balance;
        return _balance += amount;
    }
    
    public decimal Withdraw(decimal amount)
    {
        if (amount > _balance) 
        {
            Console.Write("Insufficient funds: ");
            return _balance;
        }
        return _balance -= amount;
    }
}
Output
100
70
Insufficient funds: 70
Production Trap:
Returning zero or negative balances from failed withdrawals can silently corrupt financial data — always return the unchanged balance or throw a domain exception.
Key Takeaway
Private fields with public methods enforce business rules (like overdraft protection) at the class boundary — the safest pattern in C#.
● Production incidentPOST-MORTEMseverity: high

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

Symptom
In 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.
Assumption
The developer assumed a static field would be reset per request because each HTTP request creates new objects.
Root cause
A 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.
Fix
Replace 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 guideSymptom → Action guide for common class/object misbehaviour3 entries
Symptom · 01
Two variables supposed to hold different objects show the same values after assignment
Fix
Check 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.
Symptom · 02
A property setter throws unexpected validation errors
Fix
Inspect 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.
Symptom · 03
Static counter gives unexpected values or duplicates under load
Fix
Use 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.
★ Quick Debug Cheat Sheet: Object State IssuesUse these commands and checks when you suspect object identity or state problems.
Object changed unexpectedly after method call
Immediate action
Add `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 now
If you need isolation, clone the object before passing it: var copy = original.Clone();
Static field shows wrong value across requests+
Immediate action
Check 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 now
For most production cases, replace with a per-request DI-scoped service instead of static fields.
Auto-property setter not firing+
Immediate action
Check 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 now
Run a quick search for { get; set; } in the class and consider if any of them should have validation.
Instance vs Static Members
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

1
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.
2
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.
3
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#.
4
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.
5
Production class design prioritizes immutability, small interfaces, and composition over inheritance
every public member is a permanent contract.

Common mistakes to avoid

4 patterns
×

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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between a class and a struct in C#, and when would...
Q02SENIOR
Can you explain what encapsulation means in practice — not the textbook ...
Q03SENIOR
If I pass an object to a method and modify it inside that method, does t...
Q04SENIOR
How would you design a thread-safe counter class in C#?
Q01 of 04SENIOR

What's the difference between a class and a struct in C#, and when would you deliberately choose one over the other?

ANSWER
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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between a class and an object in C#?
02
Can a C# class have multiple constructors?
03
Why should I use properties instead of just making fields public in C#?
04
When should I use static methods instead of instance methods?
05
What is the difference between a shallow copy and a deep copy?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's OOP in C#. Mark it forged?

11 min read · try the examples if you haven't

Previous
Records in C# 9
1 / 10 · OOP in C#
Next
Inheritance in C#