c# / .net

C# 9 and .NET 5: less ceremony, useful improvements

C# 9 and .NET 5: less ceremony, useful improvements

I like C# considerably more when I can see the calculation before I see the architecture. A program that reads some values, applies a rule, and writes a result should be allowed to look like those three things.

C# 9 makes that easier. Records remove repetitive data-object code. Init-only properties let an object initializer finish its work without leaving every property open for modification. Pattern matching can express a small decision directly. Top-level statements let a short executable start with what it does.

None of those features requires abandoning object-oriented programming. They give me more choice about how much of it a particular problem needs.

The version names need one clarification: the successor to .NET Core 3.1 is .NET 5, while the web framework is still called ASP.NET Core. This article uses .NET 5 and C# 9, with SDK 5.0.301 and runtime 5.0.7 from the June 8 servicing release. These are released tools, not examples from a preview of the next version.

Start with data and a rule

Imagine a small utility that summarizes warehouse readings. Each reading has a location, a temperature, and a flag indicating whether the sensor result is usable. We want the warmest valid reading at each location, classified against a simple operating range.

There is no database in this example. There is no reason for a repository interface, a sensor factory, or a base class representing every conceivable measurement. Those might become useful in a larger application. Here they would make us navigate through files to discover a comparison.

Create a directory with these three files. global.json selects the SDK:

{
  "sdk": {
    "version": "5.0.301",
    "rollForward": "disable"
  }
}

Readings.csproj is the complete project:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <LangVersion>9.0</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

And this is all of Program.cs:

using System;
using System.Globalization;
using System.Linq;

var readings = new[]
{
    new Reading("north", 18.5m, true),
    new Reading("north", 22.0m, true),
    new Reading("south", 27.5m, true),
    new Reading("south", 99.0m, false)
};

var summaries = readings
    .Where(reading => reading.IsValid)
    .GroupBy(reading => reading.Location)
    .Select(group => new Summary(group.Key, group.Max(r => r.Celsius)))
    .OrderBy(summary => summary.Location);

foreach (var summary in summaries)
{
    var temperature = summary.Peak.ToString("0.0", CultureInfo.InvariantCulture);
    Console.WriteLine($"{summary.Location}: {temperature} C ({Classify(summary.Peak)})");
}

static string Classify(decimal temperature) => temperature switch
{
    < 16m => "cold",
    >= 16m and <= 25m => "normal",
    > 25m => "warm"
};

public record Reading(string Location, decimal Celsius, bool IsValid);
public record Summary(string Location, decimal Peak);

Run dotnet run --configuration Release. The result is:

north: 22.0 C (normal)
south: 27.5 C (warm)

The invalid 99-degree reading never enters the grouping. The program sorts its output explicitly, and formatting does not depend on the machine's decimal separator. Small details, but they make a useful utility more predictable than a demonstration that only happens to work on my laptop.

LINQ is not new in this release. Neither are local functions or switch expressions. The new pieces here are the record declarations, top-level entry point, and relational and logical patterns. Keeping that distinction clear is more useful than attributing every pleasant line of C# to the latest release.

Records remove work I would otherwise have to maintain

For these inputs, the values describe the reading. Two readings with equal components should compare equal. That makes a record a reasonable fit.

A positional record provides the constructor and properties, together with value-based equality and useful printing. I could hand-write those members on a class. I would then have to remember to update all of them when a new component arrives. The shorter declaration reduces that maintenance surface.

The following statements can be inserted before the type declarations in the example:

var original = new Reading("north", 18.5m, true);
var corrected = original with { Celsius = 19.0m };

Console.WriteLine(original == new Reading("north", 18.5m, true));
Console.WriteLine(original.Celsius == 18.5m);
Console.WriteLine(corrected.Celsius == 19.0m);

All three expressions print True. The correction creates another record; it does not change the original. That is useful when an earlier processing step still needs the uncorrected input.

The C# 9 release explanation describes these semantics, including an important limit: copying is shallow. If a record contains a mutable list, copying the record does not create an independent list. Likewise, collection properties do not automatically gain element-by-element equality merely because their owner is a record.

Our example deliberately uses strings, decimals, and a Boolean. It is easy to reason about those values. For nested mutable objects, I would choose the ownership and copying rules first and the syntax second.

Init-only does not mean valid by construction

Sometimes I want an ordinary class with reference identity, but I still want configuration to stop changing after initialization. init supports that without forcing a positional record on the design.

For example, this standalone type could live in ExportOptions.cs:

public sealed class ExportOptions
{
    public string Separator { get; init; } = ",";
    public bool IncludeHeader { get; init; } = true;
}

An initializer can select a semicolon separator. Later code cannot assign another separator through the property. That gives a caller a convenient construction interface without introducing setters that remain available throughout the object's lifetime.

It does not enforce a sensible separator. A caller could still supply an empty string. Nullable analysis also does not replace validation at an input boundary. If a type must enforce an invariant, put the check in a constructor or a deliberate creation function. Convenient syntax should not decide whether invalid state is representable.

This is where I still like conventional objects: a type that owns state and protects meaningful rules can make the rest of a system simpler. A type that exists only to carry three measurements usually does not need that machinery.

Patterns should make the condition easier to check

The temperature classifier reads as three ranges. I can inspect the lower boundary, upper boundary, and remaining case without following a method call into another class. C# 9's and, or, and not patterns are helpful when they express the vocabulary of the rule.

That does not make an elaborate pattern inherently better than an if. If a decision combines configuration, permissions, database state, and an external response, squeezing it into one expression makes it harder to debug. I would split the decision into named steps.

For this classifier, the meaningful checks are the boundaries: just below 16, exactly 16, exactly 25, and just above 25. Testing only 20 would prove very little. The same applies to the pipeline: a location with only invalid readings should disappear, and input order should not determine output order.

I would also keep the data contract explicit if this grew into an importer. An unknown location, missing temperature, and rejected sensor reading are different conditions. A short program should not silently collapse them into the same default value just to keep its expression chain tidy.

Top-level statements are an entry point, not a filing system

The sample has no explicit Program.Main, but it still compiles into an ordinary executable with an entry point. Type declarations come after the top-level statements, and only one file in a project can contain that top-level program.

I find this particularly useful for importers, diagnostic commands, and small automation tools. The first line can tell the reader what the tool does. When it grows, I can move the calculation into another file without building a framework around it.

Shortness is not the acceptance criterion. If this utility starts reading large files, the grouping operation deserves another look: it retains data for groups rather than acting as a constant-memory streaming maximum. A dictionary updated as readings arrive might be the better implementation. That decision follows the workload, not the number of lines saved by LINQ.

The runtime improvements are a separate reason to upgrade

.NET 5 also improves the runtime and libraries. Microsoft's performance investigation documents changes in the JIT, garbage collection, collections, and other frequently used paths. These can benefit existing code without a rewrite into the latest syntax.

I would keep two experiments separate. First, run the same application on the old and new runtime with the same workload. Then change the application code and measure again. Otherwise a faster result tells us very little about which change helped.

Records are still reference types in C# 9, and making a copy allocates an object. A pleasant with expression is not a memory optimization. Similarly, a cleaner entry point does not remove the runtime or make startup free. For a service, measure startup, allocation rate, throughput, and tail latency under representative load. For this utility, measure total work and peak memory using a realistic input file.

The release announcement also distinguishes .NET 5 from an LTS release. A team choosing it should have an upgrade path, rather than treating a successful migration as the end of runtime maintenance. Existing .NET Framework applications also need a real compatibility assessment; changing a project target does not port Web Forms or Windows-only dependencies.

What attracts me here is fairly ordinary: fewer members to keep synchronized, clearer data flow, and a runtime worth testing against the application I already have. I can take those improvements without deciding that every class was a mistake or that every short function needs an interface.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev