Value objects: types that represent a concept by value (Money, DateRange, Email, Coordinates). Two instances with the same components should be equal.
Tiny wrappers: IDs or strongly typed strings/ints (UserId, Sku) so you do not mix them up; equality prevents multiple wrappers around the same value from behaving differently.
Structs: the default field-by-field comparison may be fine, but implement explicitly when you have invariants or want faster IEquatable<T> to avoid boxing.
Records: they already get value equality; customize only when you need to exclude fields from comparison or add domain-specific rules.
Not usually: entities tracked by identity (database row with generated key), large mutable graphs, or types where reference identity conveys lifecycle.
The equality contract to honor
Reflexive: x.Equals(x) is true.
Symmetric: x.Equals(y) == y.Equals(x).
Transitive: if x == y and y == z, then x == z.
Consistent: equal objects must return the same hash code.
Null-safe: x.Equals(null) is false; operators handle null without throwing.
A correct pattern for reference types
public sealed class Money : IEquatable<Money>
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
public bool Equals(Money? other)
{
if (ReferenceEquals(this, other))
{
return true;
}
if (other is null)
{
return false;
}
return Amount == other.Amount && Currency == other.Currency;
}
public override bool Equals(object? obj) => Equals(obj as Money);
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
public static bool operator ==(Money? left, Money? right) =>
Equals(left, right);
public static bool operator !=(Money? left, Money? right) => !(left == right);
}
Implement IEquatable<T> for speed (avoids boxing) and clarity.
Delegate Equals(object) to the typed overload.
Use HashCode.Combine (or System.HashCode accumulator) to keep hashes aligned with the fields used in equality.
Provide both == and != together or neither; keep them consistent with Equals.
Structs: prefer readonly and IEquatable<T>
public readonly struct Point : IEquatable<Point>
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) => (X, Y) = (x, y);
public bool Equals(Point other)
{
return X == other.X && Y == other.Y;
}
public override bool Equals(object? obj) => obj is Point p && Equals(p);
public override int GetHashCode() => HashCode.Combine(X, Y);
public static bool operator ==(Point left, Point right) => left.Equals(right);
public static bool operator !=(Point left, Point right) => !left.Equals(right);
}
readonly struct keeps fields immutable, making hash codes stable.
Do not rely on mutable fields for hash-based collections; mutation after insertion can break lookup.
Records, tuples, and anonymous types
record and record struct emit value equality across positional and property members by default.
Value tuples and anonymous types already compare structurally; they also implement IEquatable<T>.
Override Equals/GetHashCode on a record only when you need to ignore a property (e.g., transient CalculatedTax).
Collections: choose the comparer deliberately
Sequences: use Enumerable.SequenceEqual(a, b, comparer) for order-sensitive comparisons.
Sets: HashSet<T> and ImmutableHashSet<T> rely on the element comparer; if elements need custom rules, supply IEqualityComparer<T>.
Dictionaries: choose the key comparer (case-insensitive strings, culture-aware rules) via Dictionary<TKey, TValue>(comparer) or ImmutableDictionary.Create(comparer).
Arrays: StructuralComparisons.StructuralEqualityComparer compares elements, but SequenceEqual is usually clearer.
Example comparer for collections
public sealed class PersonIdComparer : IEqualityComparer<Person>
{
public bool Equals(Person? x, Person? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return x.Id == y.Id;
}
public int GetHashCode(Person obj) => obj.Id.GetHashCode();
}
var set = new HashSet<Person>(new PersonIdComparer());
Custom comparers decouple collection behavior from the type’s own equality; use them when the collection needs a different notion of “same”.
Testing your implementation
Verify hash code stability by inserting into HashSet<T> and confirming lookups before and after operations that should not change equality.
Add round-trip tests: objects that are equal should serialize/deserialize to an equal instance.
Include negative cases (different currency, different order in sequences) to ensure the comparer is strict enough.
Final thoughts
Decide up front whether a type is identity-first or value-first; equality follows that decision.
Implement IEquatable<T>, override Equals and GetHashCode, and keep ==/!= aligned.
For collections, pass explicit comparers so sets and dictionaries reflect the rules you intend.