C# Classes and Objects — Static Counter Invoice Collisions
Static fields share state across all instances and threads, causing duplicate invoices under load.
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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.
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).
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 simultaneously can read the same counter value before either writes, producing duplicate invoice numbers.Invoice()
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 , both variables point to the same heap object—mutating Invoice(); Invoice b = a;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.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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); } }
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); } }
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); } }
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)}"); } }
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}"); } }
- 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.
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.
// 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}");
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.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 : or this(): . 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.base()
// 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}");
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>.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.
// 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);
Where().Sorting Complex Lists with Comparison — No IComparer Required
The List<T>. overload that takes a Sort()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.
// 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})");
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.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.
// 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; } }
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.
// 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) { } }
Dispose() method, unmanaged resources may leak when consumers forget to call Dispose. Always pair finalizers with IDisposable.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.
// 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);
AsReadOnly() or return an immutable copy.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.
// 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
required members) won't compile on older runtimes.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.
// 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; } }
The Ghost Mutation: When a Static Counter Gave Customers the Same Invoice Number
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.- 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.
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.`Console.WriteLine($"Same? {ReferenceEquals(myObj, original)}");`Check method signature: parameter is class type? If yes, it receives a reference — modifications affect caller.var copy = original.Clone();Inspect AppDomain static: `typeof(MyClass).GetFields(BindingFlags.Static | BindingFlags.NonPublic)`Place a lock around writes if thread safety is required: `lock (_lockObj) { counter++; }`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.{ get; set; } in the class and consider if any of them should have validation.| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| BankAccount.cs | using System; | What a Class Actually IS |
| ProductCatalogue.cs | using System; | Constructors, Properties and Access Modifiers |
| EmployeeRegistry.cs | using System; | Static vs Instance Members |
| ReferenceSemantics.cs | using System; | Object References, Value Semantics and the new Keyword |
| ProductionClass.cs | using System; | Class Design Best Practices for Production Applications |
| InvoiceDeclaration.cs | public class Invoice | Declaration of a Class |
| OrderSyntaxSnap.cs | public class Order | Syntax Isn't Academic |
| IndexerExample.cs | public class LogBuffer | Indexers |
| SortWithDelegate.cs | public class LogEntry | Sorting Complex Lists with Comparison |
| ParameterBindingController.cs | [ApiController] | Parameter Binding in ASP.NET WebAPI |
| DestructorExample.cs | public class FileHandler | C# Destructors |
| BankAccountChallenge.cs | public class BankAccount | Challenge |
| SetupCheck.cs | dotnet --version | Before You Start |
| BankAccount.cs | BankAccount account = new(); | Create Deposits and Withdrawals |
Key takeaways
Common mistakes to avoid
4 patternsMaking fields public instead of using properties
Confusing reference assignment with copying
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
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.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
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.Interview Questions on This Topic
What's the difference between a class and a struct in C#, and when would you deliberately choose one over the other?
Can you explain what encapsulation means in practice — not the textbook definition, but how you actually apply it when designing a class?
CalculateTotalWithTax() method. This way, when tax rules change, I change one method, not fifty call sites.If 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?
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.How would you design a thread-safe counter class in C#?
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
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.
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.
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.
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.
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.
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
That's OOP in C#. Mark it forged?
9 min read · try the examples if you haven't