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.
- 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.
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.
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.
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.'
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.
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.
- 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.
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()
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.
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.
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.
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.
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.
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.
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.
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();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?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
That's OOP in C#. Mark it forged?
11 min read · try the examples if you haven't