Use the version table as a quick lookup, then open the cards for practical trade-offs. A newer feature is useful only when your SDK, target framework and team conventions support it.
| Language version | Useful capabilities | Good fit when… |
|---|---|---|
| C# 8 | Nullable reference types, async streams, ranges and indices, switch expressions | Safer APIs and more expressive iteration in modern .NET projects. |
| C# 9 | Records, init-only setters, top-level statements, improved pattern matching | Concise data models and simpler small applications. |
| C# 10 | File-scoped namespaces, global using directives, record structs | Less ceremony without changing the runtime model. |
| C# 11 | Raw string literals, required members, list patterns, generic math | Readable embedded text and explicit initialization. |
| C# 12 | Primary constructors, collection expressions, inline arrays | Reduce boilerplate while keeping allocation choices visible. |
Work through the path
Open a card when you need it. Marking a lesson complete is optional and stays only in this browser.
1Separate C# language version from .NET runtime
The compiler language version controls syntax and features; the target framework controls the runtime and base libraries.
A project can target one .NET version while using a compatible C# language version. The SDK normally chooses a sensible default, but a repository can pin LangVersion for repeatable builds.
When a feature fails to compile, check the SDK, TargetFramework, LangVersion and project SDK style before assuming the runtime is missing the feature.
Try it
Record the target framework and language version in the README so upgrade conversations are concrete.
<PropertyGroup> <TargetFramework>net8.0</TargetFramework> <LangVersion>12.0</LangVersion> <Nullable>enable</Nullable> </PropertyGroup>
2Choose records and classes by identity
Records communicate value-like data and generated equality; classes usually communicate identity, lifecycle or mutable behavior.
A request or response DTO often benefits from a record because its purpose is to carry values. An EF Core entity usually has identity and lifecycle rules that deserve a class.
The syntax is not the design decision. Ask whether two instances with the same values should be considered the same value in your domain.
Try it
Take one DTO and one EF Core entity and write down what equality and mutation should mean for each.
public sealed record CreateOrderRequest(
Guid CustomerId,
IReadOnlyList<CreateOrderLine> Lines);
public sealed class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public void Submit() => Status = OrderStatus.Submitted;
}3Treat nullable annotations as a design conversation
Nullable reference types tell the compiler where null is expected; they improve feedback but do not add runtime validation.
With nullable enabled, string communicates that null is not expected while string? communicates that it may be missing. The compiler points out paths where the guarantee is not established.
Boundaries still need real validation: model binding, databases, deserialization and external APIs can all violate assumptions at runtime.
Try it
Turn nullable on for one project and fix warnings at boundaries before reaching for the ! suppression operator.
public sealed record CustomerResponse(
Guid Id, string DisplayName, string? PreferredName);
var name = customer.PreferredName ?? customer.DisplayName;4Make asynchronous work cancellable
Pass a CancellationToken from the request boundary into I/O operations so abandoned work can stop early.
Async releases a thread while the application waits for I/O. Cancellation completes the picture: a disconnected client or timed-out job should not keep doing work nobody will consume.
Keep the token flowing through your methods and pass it to APIs that accept one. Do not turn cancellation into a generic success response.
Try it
Find an I/O method with no token. Add the token at the public boundary, then follow compiler errors inward.
public async Task<OrderDto?> GetAsync(
Guid id, CancellationToken token)
{
return await db.Orders
.AsNoTracking()
.Where(order => order.Id == id)
.Select(order => new OrderDto(order.Id, order.Status))
.SingleOrDefaultAsync(token);
}No lesson matches that phrase.
Related tools
Turn the concept into a working example with the browser-based tools.