C# Is What Happens When a Language Is Allowed to Learn From Everybody
C# began as Microsoft's answer to Java and spent its first years being described that way. It is a poor description now. Over two decades the language has absorbed ideas from functional programming, from scripting languages, from systems programming, and from its own users, and it has done so at a pace few languages sustain. That history produced an unusual property: C# is a large language where most of the size is genuinely useful. Features were not added for novelty but to solve problems people kept hitting, and the ones that failed to earn their keep were quietly left alone rather than extended. The result is a language that can be written in several styles — object-oriented, functional, or close to the metal — without any of them feeling bolted on. The cost is that C# is not small, and a newcomer meets a lot of surface area. The route through is to learn the parts in the order the language itself learned them, because each wave of features exists to fix a problem created by the previous one. Types, and the value-versus-reference distinction C# has two categories of type, and the distinction explains a great deal of behaviour that otherwise looks arbitrary. A value type — int, double, bool, DateTime, any struct — holds its data directly. Assigning one copies it. Passing one to a method copies it. A reference type — any class, string, arrays, delegates — holds a reference to data elsewhere. Assigning one copies the reference, so both names point at the same object. var a = new Point(1, 2); // struct: copied var b = a; b.X = 99; // a.X is still 1
var p = new Person("Ama"); // class: shared var q = p; q.Name = "Bo"; // p.Name is now "Bo" Nearly every "why did this change" bug in C# comes back to that distinction. The default advice is to use classes unless you have a specific reason — structs are for small, immutable, frequently created values where avoiding heap allocation matters, and a large mutable struct is a reliable source of confusion. string is the exception that confuses people: it is a reference type but behaves like a value because it is immutable. Every operation that appears to modify a string produces a new one, which is why building a string in a loop with += is quadratic and StringBuilder exists. Nullable reference types, retrofitted honestly C# spent twenty years where any reference could be null. Rather than break every existing program, the language added nullable reference types as an opt-in analysis.
nullable enable
string name = "Ama"; // the compiler will not let this be null string? nickname = null; // explicitly may be null The important thing to understand is what this is and is not. It is a compile-time analysis, not a run-time guarantee. The compiler warns where you might dereference a null, and it is genuinely effective at that, but nothing stops a library compiled without the feature from handing you a null in a non-nullable slot. It is a very good warning system rather than a proof. Turning it on in a new project costs nothing and removes the most common exception in the language. Turning it on in an old one produces a wave of warnings that is worth working through, because each one is a place where nobody had decided what should happen. LINQ, and why it was a bigger deal than it looked LINQ is query syntax built into the language, and it arrived with a set of supporting features — lambdas, extension methods, expression trees, anonymous types, type inference — that between them changed how C# is written. var topCustomers = orders .Where(o => o.Total > 100) .GroupBy(o => o.CustomerId) .Select(g => new { Id = g.Key, Spend = g.Sum(o => o.Total) }) .OrderByDescending(x => x.Spend) .Take(10); That reads as a description of the result. It is also lazy — nothing executes until the sequence is enumerated — which means the chain can be built up conditionally and only the final shape costs anything. The part that makes LINQ remarkable rather than merely pleasant is expression trees. The same query written against a database is not executed in memory; it is translated into SQL and run on the server. The syntax is identical, the semantics are not, and the gap between them is the single most common source of surprise. A method call the translator does not understand throws at run time, and an innocuous-looking query can silently fetch an entire table before filtering it in memory. Knowing which provider you are querying, and when the query actually executes, is the skill that separates people who use LINQ from people who are occasionally badly surprised by it. async/await started here C# introduced async/await to the mainstream, and the languages that followed largely copied the design. public async Task<User> GetUserAsync(int id) { var response = await _http.GetAsync($"/api/users/{id}"); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync<User>(); } The thread is released while waiting, so a server can handle far more concurrent requests than it has threads. The rules that keep this working are few and worth memorising, because breaking them produces deadlocks that are miserable to diagnose. Never block on async code with .Result or .Wait(). In some contexts this deadlocks outright, and in all contexts it wastes the thread the feature was designed to free. Return Task rather than void from an async method, except for event handlers — an async void method's exceptions cannot be caught by the caller and will usually take the process down. And use ConfigureAwait(false) in library code, where you have no business resuming on the caller's context. Properties, and the small syntax that shaped the style C# has properties: members that look like fields and run code. public string Name { get; init; } public decimal Total => Items.Sum(i => i.Price); That first line is settable only during construction, which gives immutability without a constructor full of assignments. The second is computed on every read with expression-bodied syntax. This sounds minor and is not. Because adding validation or computation to a property does not change the calling code, C# culture never developed the reflexive getter-and-setter-for-everything pattern that afflicts languages without the feature. You expose a property, and if it later needs logic, you add it without touching a single caller. Records, pattern matching, and the functional turn Recent C# has moved noticeably toward functional style. public record Order(string Id, decimal Total);
var description = order switch { { Total: > 1000 } => "large", { Total: > 100 } => "medium", null => "missing", _ => "small" }; Records give value-based equality and concise declaration. with expressions produce a modified copy rather than mutating. Pattern matching in switch expressions handles types, properties, tuples, lists and relational patterns, and the compiler checks exhaustiveness in many cases. The combination of records and exhaustive pattern matching allows the discriminated-union style that functional languages use for modelling state — and while C# does not have true unions yet, the pattern is close enough to be genuinely useful for representing "exactly one of these cases" without a bag of nullable fields. The runtime, and where .NET actually runs now The .NET platform is the other half of the story and has changed more than the language. It is now cross-platform, open source, and genuinely fast — the web stack competes with anything in its class in public benchmarks. Ahead-of-time compilation produces native binaries with fast start-up and no runtime dependency, which makes C# viable for command-line tools and serverless functions where JIT warm-up used to rule it out. Span<T> and Memory<T> allow working with slices of memory without allocating, which is how the standard library's parsing became dramatically faster without unsafe code. The practical point for a learner: assume anything you read from before the open-source consolidation is out of date about platforms and performance. Where C# fits It is strong for backend web services, desktop applications on Windows, cross-platform applications through MAUI, and game development, where Unity made it the most widely used game scripting language by a wide margin. The tooling is excellent, which is not a minor consideration — a good debugger and reliable refactoring are worth real productivity. It is less obviously the choice where the ecosystem matters more than the language: data science and machine learning overwhelmingly happen in Python, and no amount of C# quality changes that. And for very small scripts it carries more ceremony than a scripting language, although top-level statements have narrowed that gap considerably. IEnumerable, deferred execution, and the double enumeration trap Most LINQ returns an IEnumerable<T>, and that type represents a sequence that can be enumerated rather than a collection that exists. var expensive = orders.Where(o => Lookup(o.Id)); // nothing has happened yet var count = expensive.Count(); // runs Lookup for every order var list = expensive.ToList(); // runs Lookup AGAIN The query is a description, and enumerating it executes it. Enumerating twice executes it twice — which is merely wasteful over an in-memory list and genuinely serious when each item costs a database call or an HTTP request. The habit that prevents it: materialise with ToList or ToArray at the point where you know the sequence will be used more than once, and keep it lazy where you are passing it straight through. Returning IEnumerable from a public method is a decision to let the caller control when it runs, and that is fine as long as the documentation says so. IDisposable, using, and resources that do not collect themselves The garbage collector reclaims memory. It does not close files, release database connections, or free handles the operating system is holding, and it makes no promise about when it will run. Types holding such resources implement IDisposable, and the using statement guarantees cleanup at the end of a scope even if an exception is thrown. using var connection = new SqlConnection(connectionString); The classic production failure in .NET applications is connection exhaustion caused by a code path that returns early without disposing. Pool exhausted, requests queue, the application hangs under load and works perfectly in testing. The using declaration makes it structurally impossible to miss, which is why the newer syntax without braces is worth adopting everywhere. One widely misunderstood exception: HttpClient should be long-lived and shared, not created per request inside a using. Disposing it frequently exhausts sockets, which is the opposite problem with the same symptom. Exceptions, and the two things to get right C# uses exceptions throughout, and two practices account for most of the difference between a debuggable application and a frustrating one. Do not catch what you cannot handle. A catch block that logs and rethrows adds noise; one that logs and swallows destroys information. Let an exception travel to a layer that can make a decision about it. Preserve the stack trace. Writing throw ex resets the trace to the current line, discarding where the problem actually happened. Writing throw alone rethrows with the original trace intact. That one character is regularly the difference between a five-minute diagnosis and an afternoon. Exception filters are worth knowing too — a when clause lets you catch conditionally without unwinding the stack first, which keeps the original state available for debugging. Extension methods, and how LINQ was possible at all An extension method adds a method to an existing type without modifying it or inheriting from it. The compiler rewrites the call into an ordinary static method call, so there is no run-time machinery and no risk of changing behaviour for code that did not opt in. This is the mechanism that made LINQ possible. Where, Select and the rest are not members of the collection types — they are extension methods on IEnumerable<T>, which is why every sequence in the framework, including ones written years earlier, gained the entire query vocabulary without a single line of those types changing. It is a genuinely well-judged feature, and the discipline around it is simple: extend an interface rather than a concrete type where you can, keep extensions in a namespace callers import deliberately, and resist adding methods that would surprise someone reading the call site. An extension that mutates a type nobody expects to be mutable is the one way this feature goes wrong. What to practise
- Write a struct and a class with the same fields and assign each to a second variable. Mutate and observe. The distinction will stop being abstract.
- Build one LINQ query against objects and the same query against a database, and look at the generated SQL. Then write one the provider cannot translate.
- Deliberately deadlock with .Result on an async call in a context that has a synchronisation context, then fix it. You will never do it accidentally again.
- Model a state machine with records and a switch expression, and let the compiler tell you which case you missed.
- Turn on nullable reference types in an existing project and work through the warnings honestly. C# rewards someone who keeps up. The language has changed substantially every few years, and code written in the current style is markedly shorter and clearer than the equivalent from a decade ago. The main risk is learning it from old material and concluding it is more ceremonious than it is.