Levelwise
English
C# and .NET basics

Modern C#

New C# versions cut repeated code and show errors earlier. Use record for data that is known by its value, pattern matching for readable decisions, and nullable to find null before run time. Know where each tool fits and where it has a trap.

Not reviewedWritten with AI helpReading time: 16 minOnline shop order exampleC# 14 and .NET 10 code

Author: bezzad

The problem: a lot of code for a simple job

In our online shop, the order shipping address is written like this:

public class Address
{
    public string City { get; set; }
    public string PostalCode { get; set; }

    public override bool Equals(object obj) =>
        obj is Address other && City == other.City && PostalCode == other.PostalCode;

    public override int GetHashCode() => HashCode.Combine(City, PostalCode);
}

This code has a few problems:

  1. A lot of repeated code. For simple data, we must write the Equals and GetHashCode methods by hand. If a field is added and we forget to add it to Equals, we have a bug.
  2. Anyone can change it. The address of a placed order must not change silently.
  3. The null value is hidden. Nothing says that City may be empty. The NullReferenceException error is found only in Production.

New C# versions have tools for exactly these problems. In this lesson we look at the most important ones. The latest stable version is C# 14, which came with .NET 10.

The record type: data that is known by its value

The same address, with record:

public sealed record Address(string City, string PostalCode);

The compiler does these jobs for us:

  1. It creates the properties. Both get a value only when the object is created (init). After that they do not change.
  2. It creates the Equals and GetHashCode methods. Two records are equal when all their fields are equal.
  3. It creates a readable ToString method. This is useful for logs.
  4. It gives you with. A copy with a few changes.
class AddressCompared by memory addressTehran1234567890@0x01Tehran1234567890@0x02≠a == b → falseSame data, but two separate objectsrecord AddressCompared by the values of all fieldsTehran1234567890@0x03Tehran1234567890@0x04=a == b → trueSame city and postal code, so they are equal
Two classes with the same data are two separate objects. Two records with the same data are equal.
var home = new Address("Tehran", "1234567890");
var same = new Address("Tehran", "1234567890");
Console.WriteLine(home == same); // True

var office = home with { PostalCode = "1111111111" }; // a new object; home does not change
The record trap: the comparison is shallow. If a record has a List, Equals compares only the reference of the List, not its content. The with expression also copies only the reference of the List. So two records may share one List. Be careful with collection fields in a record.

When record and when class?

record

  • Data that is known by its value: an address, an amount of money, a date range.
  • Messages and DTOs: API requests and responses, events.
  • Data that must not change after it is created.

class

  • Something that has an identity and changes over time: an order, a customer.
  • Services: CheckoutService and similar.
  • Entity models in EF Core. These models are known by their ID, not by comparing all fields.

If the data is small and you do not want it created on the Heap, use record struct. For example, an amount of money with two fields.

Primary Constructor in a class

From C# 12, a class can also take constructor parameters next to its name. This style is very common for services and DI:

public sealed class CheckoutService(ShopDb db, TimeProvider clock)
{
    public async Task PlaceAsync(Order order, CancellationToken ct)
    {
        order.PlacedAt = clock.GetUtcNow();
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);
    }
}
A parameter is not a readonly field. You can assign a new value to the primary constructor parameters of a class inside the class. The compiler does not stop it. If you want to be sure it does not change, put it in a readonly field.

required and init

For a class with no constructor, the required keyword says this property must get a value when the object is created. The init keyword says it can get a value only when the object is created:

public sealed class ProductDto
{
    public required string Sku { get; init; }
    public required decimal Price { get; init; }
    public string? Description { get; init; }
}

var dto = new ProductDto { Sku = "P-907", Price = 129_500 }; // without Sku: compile error

The Nullable feature: finding null before run time

In new .NET projects, the Nullable option is on. From then on, every reference type has two forms:

  1. The string type means “never null”. If you put null in it, the compiler gives a warning.
  2. The string type with a question mark means “maybe null”. If you use it without a check, the compiler gives a warning.

The compiler follows the code path and knows where the value was checked:

Customer? c =Find(id);c.NameWarning: may be nullif (c is null)return NotFound();c.NameNo warningThe compiler learns that after this check, the value is not null
Before the check, the compiler gives a warning. After the check, it knows the value is not empty.
public Customer? FindCustomer(Guid id) => db.Customers.Find(id);

var customer = FindCustomer(id);
Console.WriteLine(customer.Name); // warning CS8602: possibly null

if (customer is null)
    return Results.NotFound();

Console.WriteLine(customer.Name); // OK: the compiler knows it is not null

So these warnings are not ignored, turn them into errors in the project file:

<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
Do not spread the exclamation mark around. The ! operator tells the compiler “be quiet, I am sure”. The warning goes away, but the problem does not. If you are wrong, you get the same NullReferenceException in Production.

One important point: this check is only at compile time. At run time, data that comes from JSON or the database can still be null. So keep validating outside input.

Readable decisions with Pattern Matching

The shipping cost rule in our shop:

  • Order of 2 million toman or more: free shipping.
  • Package heavier than 30 kg: 250 thousand toman.
  • Shipping in Tehran: 50 thousand toman.
  • Other cities: 80 thousand toman.

With if and else, this rule spreads over several nested lines. With a switch expression, the code looks almost like the business sentences:

public static decimal ShippingCost(Order order) => order switch
{
    { Total: >= 2_000_000 }     => 0,
    { WeightKg: > 30 }          => 250_000,
    { Address.City: "Tehran" }  => 50_000,
    _                           => 80_000,
};
Orderorder switch{ Total: >= 2_000_000 }{ WeightKg: > 30 }{ Address.City: "Tehran" }_Free shippingHeavy packageTehran costOther cities costTop to bottom. The first pattern that matches wins
Patterns are checked in order from the top. So the order of the lines is itself part of the rule.

A few kinds of pattern that we saw in this code:

  1. Property pattern. With curly braces, it checks the properties of the object. Even a nested property, like the city of the address.
  2. Relational pattern. With signs like greater than or less than.
  3. Discard pattern. The underscore means “anything else”.

You can combine patterns with the words and, or, and not. A list pattern also checks the shape of an array:

public static string Label(string[] tags) => tags switch
{
    []                => "no tags",
    ["sale", ..]      => "on sale",
    [var single]      => single,
    _                 => $"{tags.Length} tags",
};

if (order.Status is not (OrderStatus.Cancelled or OrderStatus.Refunded))
    await SendInvoiceAsync(order, ct);
Help from the compiler: If you work on an enum and miss one case in a switch, the compiler warns you that not all cases are covered.

Collection Expressions

From C# 12, there is one simple form to create arrays, Lists and other collections: square brackets. The two-dot sign spreads one collection inside another:

int[] featured = [101, 233, 318];
List<int> homePage = [.. featured, 450, 512];
List<OrderLine> lines = []; // an empty list

New in C# 14

Extension Members

Before, you could only write extension methods. Now, with an extension block, you can also write extension properties:

public static class OrderExtensions
{
    extension(Order order)
    {
        public bool HasFreeShipping => order.Total >= 2_000_000;
    }
}

if (order.HasFreeShipping)
    banner.Show("Free shipping!");

The field keyword

Before, if you wanted to write just one line of logic in a property setter, you also had to define a separate field. Now the field keyword points to the field that the compiler creates by itself:

public sealed class Product
{
    public required string Name
    {
        get;
        set => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Name is required.", nameof(value))
            : value.Trim();
    }
}

Null-conditional assignment

The question mark and dot operator now also works on the left side of an assignment. If the object is null, the assignment does not happen:

customer?.LastOrderAt = DateTimeOffset.UtcNow;

Important rules

  1. For data without identity, use record. For something that has an identity and changes, use class.
  2. Record comparison is shallow. Use a List inside a record with care.
  3. Keep the Nullable option on and take the warnings seriously. It is better to make them errors.
  4. Use the ! operator only when you are really sure and you know why.
  5. In a switch, the order of patterns matters. Put the more general pattern lower.
  6. Use a new feature only when it makes the code more readable. The goal is simpler code, not shorter code.

Common mistakes

Mistake Result Right way
record for an Entity in EF Core Comparison by all fields, strange behavior in change tracking. A class with an ID.
A changeable List inside a record Two records share one List and the comparison is wrong. A read-only collection, or a class.
Turning off Nullable or ignoring the warnings NullReferenceException in Production. Keep it on and make the warnings errors.
Spreading ! around to quiet the compiler The warning goes away, the problem does not. A real null check.
A general pattern above a specific one in a switch The lower lines never run. Order from specific to general.
Assuming a primary constructor parameter does not change Someone changes it in the middle of the class. A separate readonly field.

Summary in six lines

  1. The record type is for data that is known by its value. It creates Equals and with by itself.
  2. Record comparison is shallow. Use a collection inside it with care.
  3. The Nullable option shows null errors at compile time. The ! operator only hides them.
  4. A switch expression with patterns makes business rules readable. The order of patterns matters.
  5. Primary constructor parameters in a class are not readonly fields.
  6. C# 14 brought extension properties, the field keyword, and null-conditional assignment.