Records do not exist in 3.1, and what to write instead

C# 9 records arrive with .NET 5, so an immutable value type on 3.1 is written by hand — and the boilerplate is what records exist to remove.

public sealed class Money : IEquatable<Money>
{
    public int Cents { get; }
    public string Currency { get; }

    public Money(int cents, string currency)
    {
        if (cents < 0) throw new ArgumentOutOfRangeException(nameof(cents));

        Cents = cents;
        Currency = currency;
    }

    public bool Equals(Money other) => other is not null
        && Cents == other.Cents && Currency == other.Currency;

    public override int GetHashCode() => HashCode.Combine(Cents, Currency);
}

Get-only auto-properties assigned in the constructor are the 3.1 idiom for immutability and they are genuinely fine — the boilerplate is Equals, GetHashCode and the with-style copying, which is what a record generates. HashCode.Combine is the correct way to write the hash and is much better than the multiply-and-add pattern people copy. The PHP equivalent of all of this is a final class with private properties, which is the same discipline with less ceremony.