Understanding C# to Advance (with Examples)
This post is a hands-on guide to help you move from comfortable to confident in modern C#. You’ll learn the why and how behind features you’ll use daily in production code.
TL;DR
- Know your value vs reference semantics
- Embrace async/await, cancellation, and streaming
- Use pattern matching and records for clarity
- Write efficient queries with LINQ (and know when not to)
- Wire clean architecture with DI and minimal APIs
- Test async + edge cases, enable nullable reference types
- Keep performance in mind with spans and allocation-aware patterns
1) Value vs Reference Types (and how it bites)
Structs (value types) copy by value. Classes (reference types) copy references.
Bug-prone example:
public struct Counter
{
public int Value;
}
static void Increment(Counter c) => c.Value++; // operates on a copy!
var c = new Counter { Value = 0 };
Increment(c);
Console.WriteLine(c.Value); // 0 (surprise)
Fix with ref, or make it a class if you need reference semantics:
static void Increment(ref Counter c) => c.Value++;
var c = new Counter { Value = 0 };
Increment(ref c);
Console.WriteLine(c.Value); // 1
Prefer immutability when possible to avoid accidental state sharing:
public record Money(decimal Amount, string Currency);
var m1 = new Money(10m, "USD");
var m2 = m1 with { Amount = 12m }; // non-destructive mutation
Guidelines:
- Use
structfor small, immutable, copy-friendly types (e.g., coordinates, money, ids). - Use
classwhen identity, inheritance, or shared mutable state matter.
2) Allocation-aware parsing with Span/Memory
Span<T> and ReadOnlySpan<T> let you slice without allocating new strings/arrays.
Naive CSV parse allocates for each Split:
string[] parts = line.Split(','); // allocations per field
Span-based approach avoids allocations:
public static (ReadOnlySpan<char> A, ReadOnlySpan<char> B, ReadOnlySpan<char> C) First3Csv(ReadOnlySpan<char> line)
{
int i1 = line.IndexOf(',');
var a = i1 < 0 ? line : line[..i1];
if (i1 < 0) return (a, ReadOnlySpan<char>.Empty, ReadOnlySpan<char>.Empty);
var rem = line[(i1 + 1)..];
int i2 = rem.IndexOf(',');
var b = i2 < 0 ? rem : rem[..i2];
var c = i2 < 0 ? ReadOnlySpan<char>.Empty : rem[(i2 + 1)..];
return (a, b, c);
}
var (a, b, c) = First3Csv("alice,24,NY".AsSpan());
Tips:
- Use
.AsSpan()on strings for read-only spans. - Prefer
ReadOnlySpan<char>params for high-throughput parsing. - In async APIs or long-lived storage, use
Memory<T>instead.
3) Async/Await done right
Rules of thumb:
- Always accept
CancellationTokenin async methods - In library code, use
ConfigureAwait(false)to avoid capturing contexts - Use
await usingforIAsyncDisposable - Prefer streaming with
IAsyncEnumerable<T>for large datasets
Example: robust HTTP call with cancellation and streaming
public sealed class GitHubClient(HttpClient http)
{
public async Task<string> GetUserRawAsync(string id, CancellationToken ct = default)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"/users/{id}");
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
}
Async stream (server-sent sequence or paging):
public async IAsyncEnumerable<int> CountToAsync(int n, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
for (var i = 1; i <= n; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(10, ct).ConfigureAwait(false);
yield return i;
}
}
await foreach (var x in CountToAsync(5, ct))
{
Console.WriteLine(x);
}
4) Pattern Matching, Records, and Clearer Domain Models
Use property, relational, and logical patterns to encode rules declaratively:
public record Order(decimal Total, decimal Weight, string Country);
public static decimal ShippingOf(Order o) => o switch
{
{ Total: > 100m, Country: "US" } => 0m,
{ Weight: <= 1m } => 4.99m,
{ Country: "CA" } => 7.49m,
_ => 9.99m
};
Combining guards (when) keeps branches tight:
public static string RiskLevel(int score) => score switch
{
< 0 or > 100 => throw new ArgumentOutOfRangeException(nameof(score)),
>= 80 => "High",
>= 50 => "Medium",
_ => "Low"
};
5) LINQ without regrets
LINQ is expressive but can hide expensive multiple enumerations.
Bad:
var query = orders.Where(o => o.Total > 100m);
var count = query.Count(); // 1st enumeration
var list = query.ToList(); // 2nd enumeration (expensive source = twice the cost)
Better:
var list = orders.Where(o => o.Total > 100m).ToList();
var count = list.Count; // already in memory
Grouping and top-N:
var topCustomers = orders
.GroupBy(o => o.CustomerId)
.Select(g => new { CustomerId = g.Key, Total = g.Sum(o => o.Total) })
.OrderByDescending(x => x.Total)
.Take(10)
.ToList();
Tips:
- Keep queries lazy until the edge, then materialize once.
- For DB queries (EF Core), keep as
IQueryableuntil the DB; for in-memory, useIEnumerable. - For hot paths, prefer loops or
Span<T>over LINQ to reduce allocations.
6) Exceptions and the Result pattern
Reserve exceptions for exceptional cases, not control flow. Prefer Try... APIs:
if (!int.TryParse(input, out var value))
return Problem("Invalid number.");
A lightweight Result type can improve clarity:
public readonly record struct Result<T>(bool Ok, T? Value, string? Error)
{
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Fail(string error) => new(false, default, error);
}
public static Result<int> ParsePositiveInt(string s)
{
if (!int.TryParse(s, out var v)) return Result<int>.Fail("Not a number");
if (v <= 0) return Result<int>.Fail("Must be positive");
return Result<int>.Success(v);
}
7) Dependency Injection and Minimal APIs (clean wiring)
Minimal API with DI, typed client, and options:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<MyOptions>()
.Bind(builder.Configuration.GetSection("My"))
.ValidateDataAnnotations();
builder.Services.AddHttpClient<GitHubClient>(c =>
{
c.BaseAddress = new Uri("https://api.github.com/");
c.DefaultRequestHeaders.UserAgent.ParseAdd("myapp/1.0");
});
builder.Services.AddScoped<IMailer, SmtpMailer>();
var app = builder.Build();
app.MapGet("/users/{id}", async (string id, GitHubClient gh, ILoggerFactory lf, CancellationToken ct) =>
{
var logger = lf.CreateLogger("Users");
logger.LogInformation("Fetching {Id}", id);
return Results.Text(await gh.GetUserRawAsync(id, ct));
});
app.Run();
public sealed class MyOptions
{
public required string ApiKey { get; init; }
}
public interface IMailer { Task SendAsync(string to, string subject, string body, CancellationToken ct = default); }
public sealed class SmtpMailer : IMailer
{
public Task SendAsync(string to, string subject, string body, CancellationToken ct = default)
=> Task.CompletedTask; // stub
}
Notes:
- Prefer typed clients over
IHttpClientFactory.CreateClient("name")for testability and cohesion. - Validate options at startup to fail fast.
8) Testing async, data-driven, and edge cases
xUnit examples:
public class MathTests
{
[Theory]
[InlineData("5", 5)]
[InlineData("01", 1)]
public void ParsePositiveInt_Valid(string input, int expected)
{
var r = ParsePositiveInt(input);
Assert.True(r.Ok);
Assert.Equal(expected, r.Value);
}
[Theory]
[InlineData("-1")]
[InlineData("abc")]
public void ParsePositiveInt_Invalid(string input)
{
var r = ParsePositiveInt(input);
Assert.False(r.Ok);
Assert.NotNull(r.Error);
}
[Fact]
public async Task CountToAsync_CanCancel()
{
using var cts = new CancellationTokenSource(1);
await Assert.ThrowsAsync<TaskCanceledException>(async () =>
{
await foreach (var _ in CountToAsync(1000, cts.Token)) { }
});
}
}
9) Nullable Reference Types (NRT) = fewer bugs
Turn on NRT and fix warnings instead of bugs in prod.
In your .csproj:
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>true</WarningsAsErrors>
</PropertyGroup>
Use ? for references that can be null, and handle them:
string? maybeName = GetNameOrNull();
if (string.IsNullOrWhiteSpace(maybeName))
return;
Console.WriteLine(maybeName.Length);
10) Performance-minded checklist
- Prefer
readonly structandinparameters for large value types you only read - Avoid unnecessary allocations (watch string concatenation in loops; use
StringBuilderor spans) - Cache regexes:
private static readonly Regex Rx = new("pattern", RegexOptions.Compiled); - Use
ArrayPool<T>for large, transient buffers - Minimize boxing: prefer generics over
object, avoidstruct+interfacehot paths - Measure with BenchmarkDotNet; don’t guess
11) EditorConfig/Analyzers to keep code sharp
Adopt consistent rules and fail CI on critical warnings.
# .editorconfig
root = true
[*.cs]
dotnet_diagnostic.CS8618.severity = error # Non-nullable uninitialized
dotnet_diagnostic.CS8602.severity = error # Dereference of possibly null
dotnet_style_null_propagation = true:suggestion
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_prefer_primary_constructors = true:suggestion
Putting it together: a tiny, robust pipeline
public static async Task ProcessUsersAsync(Stream input, GitHubClient gh, IMailer mail, CancellationToken ct)
{
using var reader = new StreamReader(input);
string? line;
while ((line = await reader.ReadLineAsync(ct)) is not null)
{
var span = line.AsSpan();
var comma = span.IndexOf(',');
if (comma < 0) continue;
var id = new string(span[..comma]);
var email = new string(span[(comma + 1)..]);
string json = await gh.GetUserRawAsync(id, ct);
if (json.Contains("\"site_admin\": true", StringComparison.Ordinal))
{
await mail.SendAsync(email, "Welcome Admin", "Hi there!", ct);
}
}
}
This combines spans, async IO, DI-friendly components, and careful string handling.
Where to go next
- Official C# docs and language reference
- .NET Performance docs + BenchmarkDotNet
- Async guidance by Stephen Toub (blogs and talks)
- EF Core docs for LINQ-to-Entities specifics
Master these foundations and your day-to-day C# will become faster, clearer, and safer.