This chapter is a working list of small habits that make C# code easier to read, harder to break, and friendlier to the next person who opens the file. Each tip is a tiny rule with a real reason behind it, illustrated against a single e-commerce model: Product, Order, ShoppingCart, and Customer. None of these are syntactic tricks for their own sake. They each remove a class of bug or noise that shows up in everyday C# code.
Coding standards and clean-code principles cover the broad strokes: naming, formatting, single responsibility. Those are the chapter before and the chapter after this one. The middle layer, the one that actually decides whether a method reads cleanly, is a set of small choices: do you write var or the full type, an auto-property or a field, a using block or a using declaration. C# has accumulated a lot of these choices over the last fifteen years, and most of them have a clearly better default for new code.
The cost of getting them wrong isn't usually a crash. It's worse than that. It's a function that's three lines longer than it should be, a public field that someone now depends on, a null that slips through a chain of method calls because nobody used ?., an IEnumerable chain that gets enumerated three times because the caller stored it in var and didn't think about it. Each individual choice is forgettable. The accumulation is what makes a codebase feel either tight or tired.
The tips here are picked because they pay off immediately. You can apply each one to the very next line of code you write.
var When the Type Is ObviousC# has had var since version 3. The compiler still knows the exact type; var just lets you skip writing it on the left side when it's already on the right. The rule of thumb is: if a reader can answer "what type is this?" in under a second by glancing at the same line, var is fine.
The type is sitting right there on the right side. Writing List<Product> products = new List<Product>(); repeats the type for nothing, which is exactly the noise var was added to remove.
When the type isn't obvious from the right side, prefer the explicit form:
You can't tell from CalculateOrderTotal(cart) whether it returns decimal, double, or int. Writing the type makes the contract clear at the call site. The same goes for any method whose return type you'd have to chase to figure out.
A short version of the rule:
| Right side | Use var? |
|---|---|
new List<Product>() | Yes |
new[] { 1, 2, 3 } | Yes |
"hello", 42, 3.14m | Either (some teams say no for primitives) |
LookupCustomer(email) | No, write the type |
cart.Items.Where(x => x.IsActive) | No, write IEnumerable<CartItem> |
The middle case is a style preference. Some teams write int count = 0; instead of var count = 0; because the literal types of numbers aren't always obvious (is 0 an int or could it default elsewhere?). Pick one and stay consistent within a project.
C# has two ways to expose data on a type: a public field, and a property. They look similar at the call site but behave very differently when you need to change anything later.
A public field:
An auto-property:
The auto-property is the one you want for new code. Three reasons.
First, you can add validation later without breaking callers:
Callers still write product.Price = 29.99m;. Nothing on the outside changed. If Price had been a field, the same change would force every caller to use a method instead, breaking source compatibility everywhere.
Second, properties play nicely with data binding, serialization (System.Text.Json, EF Core), reflection-based frameworks, and analyzers. Many libraries simply ignore public fields. Make Price a field and your JSON deserializer might quietly drop it.
Third, properties can be made write-once with init, which lets you build immutable types without losing the convenient new Product { Name = "Mug" } syntax. That's the next tip.
When the value should be set during construction and never changed afterward, mark the setter init:
The required modifier (C# 11) tells the compiler that callers must set this property in the object initializer. Forget to set Name and the code doesn't compile.
That's the short version of why C# treats properties as the default and fields as an internal detail. Public fields work, but they trap you the moment requirements change.
Mutable state is the easiest way to introduce bugs into a small program and the easiest way to introduce nightmares into a large one. A Product that can change its price after creation has to be guarded everywhere it's used. A Product that can't change is safe to pass around, cache, and share between threads.
C# gives you three lightweight tools for immutability, each useful in a different spot.
The first is readonly on fields. The field can only be assigned in its declaration or in the constructor:
OrderId and PlacedAt can't be reassigned later. The compiler enforces this. For private state inside a class, readonly is a free upgrade you should apply by default.
The second is init on properties. It's the property-equivalent of readonly: callers can set it during object construction (including through object initializers) and never again.
The third is record types, which are the most concise way to declare an immutable data carrier:
The with expression makes a copy with one property changed; the original is untouched. Records also give you structural equality (two Product values with the same fields compare equal), a readable ToString, and deconstruction, all without writing them yourself.
When to use what:
| Need | Tool |
|---|---|
| Internal field that shouldn't change after construction | readonly field |
| Public property set only during construction | init property |
| Whole type is an immutable data carrier (DTO, value, message) | record |
| Type has behavior, identity, mutable state | regular class |
Records aren't a free upgrade for every class. ShoppingCart mutates over time as the customer adds and removes items, so it's a class. Product is a piece of catalog data that doesn't change once defined, so it's a record. Customer could go either way depending on whether you treat customers as data (record) or as entities with mutable profile state (class).
The diagram is a starting point, not a strict decision tree. A Customer with mutable address but a stable identity might be a class with mostly init properties and a small number of methods that update the mutable parts. There's room for judgment.
The => form is fine for short, single-expression members. It cleans up properties, methods, and constructors when the body is a one-liner.
Each member fits on one line. The intent is obvious. Block bodies for these would be pure noise.
The trap is using => when the body isn't really a one-liner anymore. A few patterns to watch for:
Yes, it's technically one expression. No, it's not easier to read than a switch expression or a sequence of if statements. The block form (or pattern matching, the next tip) wins here:
Another anti-pattern is using => for a method that needs to throw, log, or do anything that isn't really an expression:
The throw form works (C# 7 added throw expressions), but the moment you want to log the miss, add a metric, or branch on whether the caller wanted to allow misses, you have to convert back to a block body. The block form was right from the start.
A reasonable rule:
| Body | Use =>? |
|---|---|
| Single expression, fits on the same line | Yes |
| Single expression, wraps to a second line | Often, but consider block |
| Multiple statements | No |
| Needs a local variable | No |
| Stacked ternaries | No, use switch expression or block |
Expression-bodied members are syntax only. The compiler emits the same IL as the block form, so there is no runtime difference. The choice is purely about whether the reader can scan the member in one glance.
Cascaded if/else if chains and switch statements are some of the dustiest corners of older C# code. Pattern matching, added in C# 7 and expanded steadily since, replaces most of them with something shorter and harder to get wrong.
The simplest form is is with a type pattern, which combines a type check and a cast into one step:
Inside the if, text is already typed as string. No second cast, no as plus null check.
For multi-way branching, the switch expression (C# 8) is usually cleaner than a switch statement:
Compare that to the older switch statement: same logic, more boilerplate, easy to forget a break.
Property patterns let you match on the shape of an object:
Read it top to bottom: free shipping over $100, free for prime members, $4.99 for single-item orders, $7.99 otherwise. The same logic written as nested ifs is roughly three times the size and twice as easy to get wrong.
A small comparison of the two styles:
The old form works. It's also longer, repeats the order. prefix, and forces the reader to mentally collect the cases. The switch expression presents them as a table.
Pattern matching shines most when you're collapsing a sequence of related checks. It's not a hammer for every conditional. A single if (x > 0) doesn't need to become a switch expression. Use it where the alternative is a chain of similar-shaped branches.
String interpolation ($"...") has been the preferred way to build strings since C# 6. There are three older forms you'll still find in old codebases, and one new form for hot paths.
The four ways to build a customer greeting:
Three reasons interpolation wins for new code:
{0} and {1} to the right argument in your head.${amount:F2} or {placed:yyyy-MM-dd}. With String.Format the formatter is one place and the value is somewhere else.ILogger.LogInformation($"User {userId} did X"); doesn't pay for the formatted string when info-level logging is disabled.Format specifiers come straight from the standard numeric and date format strings:
The comma is alignment (width + direction). The colon introduces the format specifier. Both are part of the standard format-string syntax inherited from String.Format, and they work inside $"..." exactly the same way.
There's one case where concatenation is fine: building a string from a small handful of parts when there's no formatting involved. var path = root + "/" + file; reads clearly enough. The rule isn't "never concatenate" but "use $"..." first."
Building a string in a loop with += allocates a new string every iteration because strings are immutable. For more than a few iterations, use StringBuilder. Interpolation inside the loop has the same cost as concatenation, so the rule is about the loop, not the syntax.
new()C# has been quietly shrinking the syntax for creating collections and objects. Two features in particular cut a lot of repetition.
Target-typed new() (C# 9) lets you skip the type name on the right side when the type is already on the left:
The new() is short for "construct an instance of whatever type the left side says." It's the mirror image of var: var removes the type on the left, target-typed new() removes it on the right. You use one or the other, not both. var x = new(); doesn't compile, because there's no type for the compiler to infer.
Collection expressions (C# 12) take this further for collections specifically. They let you initialize a list, array, span, or any compatible collection type with a single bracket-delimited literal:
The [1, 2, 3, 4] syntax picks the right constructor or factory method for the target type. Arrays, List<T>, Span<T>, ReadOnlySpan<T>, ImmutableArray<T>, and most BCL collections all work. The .. spread operator inlines another collection's contents.
A small running example tying both features together:
The fields use new() because their types are already declared. The expected array uses a collection expression because it's a literal list of values. Neither change is dramatic, but together they remove a lot of visual repetition.
A small caveat: target-typed new() doesn't help when the type isn't already nailed down (constructor arguments, method calls, return statements where the method's return type is the only signal). Use it where it actually shortens the line, not as a search-and-replace.
When a method needs to return more than one value, the old way was an out parameter or a custom result type. C# 7 added value tuples as a built-in alternative, which work especially well with deconstruction at the call site.
The return type (decimal subtotal, decimal tax, decimal total) is a tuple with named elements. At the call site, var (sub, tax, total) = ... destructures the tuple into three local variables. You can also keep the tuple as a single value if you want to pass it along:
When to use a tuple return versus a custom type:
| Situation | Choice |
|---|---|
| Two or three closely related values, used together at the call site | Tuple |
Values that need a name as a type (PriceBreakdown used in many places) | Custom record |
| Public API of a library | Custom record (better for documentation and evolution) |
| Internal helper returning success + value | Tuple (bool success, T value) or TryXxx pattern with out |
The C# convention for "try" methods (like int.TryParse) is still bool return plus out, because callers tend to use it inside an if. For other multi-value returns, tuples have largely replaced out parameters.
Deconstruction also works on regular types if they expose a Deconstruct method. Records do this automatically:
The underscore (_) discards the value you don't need. Records generate a Deconstruct(out string Name, out decimal Price, out int Stock) method internally, which is what the destructuring assignment calls.
?., Null-Coalescing ?? and ??=Null handling used to be the noisiest part of C# code. Three operators clean most of it up. They don't replace null checks where the logic actually depends on null, but they collapse the routine cases.
The null-conditional operator ?. returns null instead of throwing when the left side is null:
You can chain them, and the chain stops at the first null:
If customer is null, the whole chain is null. If customer exists but ShippingAddress is null, same thing. Only when both are non-null does Country get read. The non-?. form would need three nested ifs.
The null-coalescing operator ?? gives a fallback when the left side is null:
If anything in the chain is null, country becomes "Unknown". Note the left of ?? was string? (nullable), and the right side is a string literal, so the whole expression is non-nullable. The compiler understands that.
The ??= form assigns only if the left side is null:
That's equivalent to if (items is null) items = new List<OrderItem>(); but shorter. Common for lazy initialization of optional fields.
A combined example:
Two things to watch out for. First, ?. returns the value's nullable form, so customer?.Age (where Age is int) is int?, not int. Combine with ?? if you need a non-nullable result. Second, ?. is shallow null handling: if a null here means something has gone wrong, hiding it behind ?. is just delaying the failure. Use the operators where null is a real, expected case, not where it's a bug you don't want to think about.
using Declarations Over using BlocksC# has had using blocks since version 1 for disposable resources. C# 8 added using declarations, which do the same thing with less indentation.
The block form:
The declaration form:
Both forms call reader.Dispose() when the variable goes out of scope. The block form disposes at the closing brace of the using block. The declaration form disposes at the end of the enclosing scope (in this case, the method). For most short methods, the declaration form is identical in effect and one level less indented.
When to keep the block form:
A small example where the block form is the standard approach:
The block ensures the writer flushes and the stream closes before File.SetAttributes runs. If those had been using declarations, they wouldn't dispose until the method exited, and SetAttributes might hit a file that still had a pending write.
The full set of pitfalls with IDisposable, async disposal, and resource leaks belongs to a later chapter. For most everyday code, prefer the declaration form and use the block form when the lifetime needs to end before the enclosing scope does.
IEnumerable<T> and IReadOnlyList<T> in Public APIsA method's return type is a contract. The narrower you can make it without losing information the caller needs, the more flexibility you have to change the implementation later.
A common mistake is returning a concrete collection type:
Two problems. First, callers can now mutate the list you returned (catalog.GetActiveProducts().Add(...)), and depending on the implementation, that might or might not affect the internal state. Second, you can't change the return type later without breaking every caller that wrote List<Product> active = catalog.GetActiveProducts();.
A better signature uses the narrowest interface that gives the caller what they actually need:
IEnumerable<T> says: you can iterate this once. Nothing about indexing, counting, or modification. The implementation is now free to be a list, an array, an iterator method, a lazy LINQ query, or a stream over a database.
If callers need indexed access or a Count, use IReadOnlyList<T>:
IReadOnlyList<T> says: indexed access and Count are allowed, but the caller cannot add, remove, or replace items. List<T> implements IReadOnlyList<T>, so the cast is free.
A short cheat sheet:
| Caller needs | Return type |
|---|---|
| Just iterate, maybe filter further | IEnumerable<T> |
Indexed access or Count | IReadOnlyList<T> |
| Set semantics (membership check) | IReadOnlySet<T> |
| Key lookup | IReadOnlyDictionary<TKey, TValue> |
| Caller must add or remove | ICollection<T> or IList<T> (rare in returns) |
The rule applies to parameters too, though for slightly different reasons. A method that takes IEnumerable<Product> can be called with a list, an array, a HashSet, or a LINQ chain. A method that takes List<Product> rejects all of those except the first.
IEnumerable<T> can be enumerated more than once. If your method walks the same input twice, and the caller passed a deferred LINQ query, you'll execute the query twice. Either materialize once (var list = items.ToList();) or document that the input must be a materialized collection.
The pattern reads as: narrow first, widen only when a caller has a concrete need you can't meet otherwise. The reverse direction (start with List<T> and try to narrow later) almost never works in a codebase with real callers.
A small e-commerce example that uses every tip in this chapter together. The model has four types: Product, Customer, ShoppingCart, and Order. The behavior is intentionally small (add items, apply a coupon, place an order), but the shape of the code shows what the tips look like when they're used as defaults instead of one-off tricks.
What's in there, tip by tip:
Product, Customer, and Order are records: immutable data carriers with structural equality. Customer adds IsPrimeMember as an extra init property.ShoppingCart is a class because the cart mutates as items are added.Owner is a required init property: the cart can't be constructed without it.var is used where the type is obvious from the right side; explicit types are used elsewhere (the Place method's Order construction names every argument).ApplyCoupon, the Items property).switch expression on the coupon code.{subtotal:C}).new() is used for the items list. The Place method uses tuple deconstruction (var (_, _, total) = ...) to grab just the total.IReadOnlyList<...> so callers can read but not modify them.null => 0m).using-free model because there's no disposable resource. If we added a FileBackedCart, it would use using var inside the persistence methods.Not every method has to use every tip. When each one is the default choice, the resulting code tends to be smaller, safer, and easier to change later.
10 quizzes