System.Object's members and contracts, reviewed through a broken Equals override: the Equals/GetHashCode contract, IEquatable<T>, operators, inheritance pitfalls, records, ToString and MemberwiseClone.
System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.
I start with what System.Object provides, then go straight to the bug: overriding Equals without GetHashCode breaks the equality contract, so hash-based collections (Dictionary, HashSet, LINQ Distinct, GroupBy) silently misbehave. Then I show a correct implementation and when a record is the better answer.
System.Object gives every type| Member | Default behaviour | Override? |
|---|---|---|
Equals(object?) |
Reference equality for classes; field-wise for structs (ValueType) |
When the type has value semantics |
GetHashCode() |
Identity-based for classes (stable per object); field-based for structs | Always together with Equals |
ToString() |
Fully qualified type name | For logging/debugging; don't put sensitive data in it |
GetType() |
Runtime type (non-virtual) | Cannot override |
MemberwiseClone() (protected) |
Shallow copy | Used to implement cloning |
Finalize() (protected) |
Nothing | Only for unmanaged resources (via ~Type()), very rarely |
static Equals(a, b), ReferenceEquals(a, b) |
Null-safe equality / pure reference check | — |
public class Customer
{
public string Email { get; set; } = "";
public string Name { get; set; } = "";
public override bool Equals(object? obj) => obj is Customer c && c.Email == Email;
// GetHashCode NOT overridden (compiler warning CS0659)
}
The contract: if a.Equals(b) is true, then a.GetHashCode() == b.GetHashCode() must be true. Here two customers with the same email are "equal" but have different identity-based hash codes.
HashSet<Customer> / Dictionary<Customer, ...>: lookups go to the bucket chosen by the hash code, so an "equal" customer is not found, and duplicates are inserted.Distinct(), GroupBy(), Union(), ToDictionary() use hashing: duplicates survive or groups split.Contains on a List<T> works (it uses Equals only), so tests that use lists pass while production code using sets fails — the bug hides.Further problems in this code:
Email has a public setter. Even with a correct GetHashCode, changing the email of an object already inside a HashSet or used as a dictionary key strands it in the wrong bucket."[email protected]" and "[email protected]" are the same mailbox domain-wise but not equal here; it should use StringComparison.OrdinalIgnoreCase (and the same comparer in the hash).IEquatable<Customer>: generic collections call Equals(object), which needs a type check per call.== overload: a == b still compares references, so Equals and == disagree — confusing.obj is Customer means a VipCustomer can equal a Customer but not necessarily vice versa if VipCustomer overrides — symmetry breaks. Compare GetType() or seal the class.CustomerId); two customers can share an email over time. Equality by email may be the wrong model.public sealed class Customer : IEquatable<Customer>
{
public Customer(string email, string name) { Email = email; Name = name; }
public string Email { get; } // immutable key
public string Name { get; set; }
public bool Equals(Customer? other) =>
other is not null &&
(ReferenceEquals(this, other) || string.Equals(Email, other.Email, StringComparison.OrdinalIgnoreCase));
public override bool Equals(object? obj) => Equals(obj as Customer);
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Email);
public static bool operator ==(Customer? a, Customer? b) => a?.Equals(b) ?? b is null;
public static bool operator !=(Customer? a, Customer? b) => !(a == b);
}
If the type is data with value semantics (DTOs, value objects like EmailAddress, Money, Address), a record generates Equals, GetHashCode, ==, !=, IEquatable<T>, ToString and an EqualityContract that handles inheritance correctly:
public sealed record EmailAddress
{
public EmailAddress(string value) => Value = value.Trim().ToLowerInvariant();
public string Value { get; }
}
Caveats: records compare all fields (often not what you want for entities), and collection-typed members compare by reference (List<T> fields make two records unequal even with the same items).
For entities (EF Core), I usually do not override equality at all, or compare by ID only once the ID is assigned — EF Core relies on reference identity in its change tracker.
A de-duplication step in a marketing export used customers.Distinct() on a class that overrode Equals by email but not GetHashCode. Unit tests used List.Contains and passed. In production, the export contained duplicates and some customers received the same email three times — a complaint risk and a Spam Act 2003 concern. The fix was a proper IEquatable implementation with a case-insensitive hash, plus a test that put two equal instances into a HashSet<Customer> and asserted a count of one. We also enabled CS0659 as an error so the compiler blocks this mistake.
A classic code-review question that reveals whether the candidate understands how hashed collections work.
Green flags
GetHashCode immediately and explains buckets.== consistency.IEquatable<T>, HashCode.Combine or records.Red flags
GetHashCode must be unique.Equals must be:
x.Equals(x) is true.x.Equals(y) == y.Equals(x).x.Equals(y) and y.Equals(z) then x.Equals(z).x.Equals(null) is false and never throws.GetHashCode must:
int to minimise collisions."abc".GetHashCode() differs between runs.Unequal objects may share a hash code (collisions are allowed); equal objects must share one.
Dictionary<TKey,TValue> uses themhash = comparer.GetHashCode(key).hash % buckets.Length (with fast modulo via multiplication on 64-bit).comparer.Equals(entry.key, key) only when hashes match.If two equal keys produce different hashes, they land in different buckets and Equals is never called — so overriding Equals alone does nothing for dictionaries. If GetHashCode returns a constant (return 1;), everything lands in one bucket and lookups degrade from O(1) to O(n).
object.GetHashCode() for classes: an identity hash, generated on first call and stored in the object header (or the sync block if the header is already used by a lock). It never changes for the object's lifetime, regardless of field values.object.Equals: ReferenceEquals(this, obj).ValueType.Equals: field-by-field, using a fast bitwise compare when the struct has no references or padding, otherwise reflection.ValueType.GetHashCode: historically based on the first non-null field; may be poor. Always override for structs used as keys.For record Person(string Name, int Age) the compiler emits roughly:
protected virtual Type EqualityContract => typeof(Person);
public virtual bool Equals(Person? other) =>
(object)this == other ||
(other is not null && EqualityContract == other.EqualityContract &&
EqualityComparer<string>.Default.Equals(Name, other.Name) &&
EqualityComparer<int>.Default.Equals(Age, other.Age));
public override int GetHashCode() =>
HashCode.Combine(EqualityContract, Name, Age); // conceptually
EqualityContract ensures a derived record (Employee : Person) is never equal to a base Person with the same values — solving the symmetry problem that hand-written obj is Customer checks have. You can customise by writing your own Equals(Person? other) and GetHashCode in the record (for example to compare a list property with SequenceEqual).
HashCode.Combine and good hash functionsHashCode.Combine(a, b, c) (.NET Core 2.1+) uses xxHash32 with a per-process random seed, giving good distribution and resistance to collision attacks. Older patterns like hash = hash * 31 + field are fine but weaker. For strings with custom comparison, use the matching comparer's hash: StringComparer.OrdinalIgnoreCase.GetHashCode(s) — using s.ToLower().GetHashCode() allocates and can disagree with your Equals on culture edge cases.
EF Core's change tracker uses reference identity and key values to track entities. Overriding Equals/GetHashCode on entities with mutable properties (or with database-generated IDs that change from 0 to a real value after SaveChanges) can break collections of entities in navigation properties: an entity added to a HashSet<OrderLine> with hash based on Id = 0 is "lost" after the ID is assigned. Common guidance:
GetHashCode based on something immutable.ToString and loggingOverriding ToString improves debugging and logs (the debugger shows it in watch windows; [DebuggerDisplay] is a non-invasive alternative). Records generate a ToString printing all public properties — including personal information such as emails or tax file numbers. Under the Privacy Act and APP 11, logging that data needlessly is a risk; override PrintMembers or ToString on records that contain personal data.
Customer into a HashSet and print the count.GetHashCode, then mutate Email of an object already in the set and try Contains.record Person and record Employee : Person with the same values and compare them."abc".GetHashCode() in two separate runs.namespace Types;
// Correct hand-written equality for a class keyed by email (case-insensitive)
public sealed class Customer : IEquatable<Customer>
{
public Customer(string email, string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(email);
Email = email.Trim();
Name = name;
}
public string Email { get; } // immutable key
public string Name { get; set; } // not part of equality
public bool Equals(Customer? other) =>
other is not null && StringComparer.OrdinalIgnoreCase.Equals(Email, other.Email);
public override bool Equals(object? obj) => Equals(obj as Customer);
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Email);
public static bool operator ==(Customer? left, Customer? right) =>
left is null ? right is null : left.Equals(right);
public static bool operator !=(Customer? left, Customer? right) => !(left == right);
public override string ToString() => $"Customer({Name})"; // no email in logs
}
// Value-like data: let the compiler generate everything
public sealed record Address(string Street, string Suburb, string State, string Postcode);
public static class EqualityDemo
{
public static void Main()
{
var a = new Customer("[email protected]", "Ada");
var b = new Customer("[email protected]", "Ada L.");
var set = new HashSet<Customer> { a, b };
Console.WriteLine(set.Count); // 1
Console.WriteLine(a == b); // True
Console.WriteLine(ReferenceEquals(a, b)); // False
var home = new Address("1 George St", "Sydney", "NSW", "2000");
Console.WriteLine(home == home with { }); // True: value equality
Console.WriteLine(home); // Address { Street = ..., ... }
}
}
Equals without GetHashCodeWhat happens: the compiler warns (CS0659) and the warning is ignored.
Why it is wrong: equal objects get different hash codes, so Dictionary, HashSet, Distinct and GroupBy treat them as different.
Do instead: always override both together (and treat CS0659/CS0661 as errors), or use a record.
What happens: GetHashCode uses properties with public setters, and objects are modified while stored in sets or used as keys.
Why it is wrong: the object stays in the bucket for its old hash, so it can no longer be found or removed.
Do instead: base equality on immutable values; make key properties get-only or init.
Equals and GetHashCodeWhat happens: Equals compares emails case-insensitively but GetHashCode uses Email.GetHashCode().
Why it is wrong: "[email protected]" and "[email protected]" are equal but hash differently.
Do instead: use the same comparer for both: StringComparer.OrdinalIgnoreCase.GetHashCode(Email).
is checks that break symmetry with inheritanceWhat happens: obj is Customer c in a non-sealed class with derived types that add fields.
Why it is wrong: customer.Equals(vip) can be true while vip.Equals(customer) is false.
Do instead: seal the class, compare GetType(), or use records (which use EqualityContract).
Equals but not ==What happens: a.Equals(b) is true but a == b is false.
Why it is wrong: developers use both interchangeably; inconsistent results cause subtle bugs.
Do instead: overload == and != consistently for value-like types (records do this automatically).
What happens: EF Core entities are made records or compare all properties.
Why it is wrong: entities have identity, change over time, and EF's change tracker relies on reference identity; navigation collections break.
Do instead: use records for value objects and DTOs, not tracked entities.
What happens: record Order(int Id, List<Line> Lines) instances with identical lines compare unequal.
Why it is wrong: generated equality uses EqualityComparer<List<Line>>.Default, which is reference equality.
Do instead: use ImmutableArray<T> with custom equality, or override Equals to use SequenceEqual.
GetHashCode valuesWhat happens: using name.GetHashCode() as a cache key in Redis, a partition key, or a database column.
Why it is wrong: string hash codes are randomised per process, so the value changes on restart and differs between servers.
Do instead: use a stable hash (XxHash64, SHA-256) from System.IO.Hashing or System.Security.Cryptography.
Equals or GetHashCodeWhat happens: Equals dereferences obj without a null check, or GetHashCode throws when a field is null.
Why it is wrong: collections call these methods implicitly, so exceptions appear from deep inside framework code.
Do instead: handle nulls and type mismatches by returning false; use HashCode.Combine, which accepts nulls.
ToStringWhat happens: record-generated ToString includes emails, phone numbers or addresses, which end up in logs.
Why it is wrong: unnecessary collection and storage of personal information conflicts with the Privacy Act (APP 11) and expands the impact of a data breach.
Do instead: override ToString/PrintMembers to exclude sensitive properties, or use structured logging with explicit fields.
Equality is a domain modelling decision: identity or value.
| Kind of type | Equality | Implementation |
|---|---|---|
Entity (Customer with Id) |
identity | reference or Id only |
Value object (Money, Address) |
all values | record / readonly record struct |
| DTO / message | values | record |
| Dictionary key | stable values | immutable fields + IEquatable<T> |
| Polymorphic hierarchy | rarely value equality | avoid, or records with EqualityContract |
IEqualityComparer<T> (StringComparer.OrdinalIgnoreCase, custom comparers) to collections instead of baking context-specific equality into the type.HashCode.Combine produces good distribution and is randomised per process, so never persist hash codes.Equals or use immutable collections plus a comparer.A hash table turns a key into an integer hash, maps it to a bucket (hash % bucketCount), and stores the entry there. Lookups compute the same hash, go straight to the bucket, and compare keys with Equals. Average cost is O(1); worst case (everything in one bucket) is O(n).
.NET's Dictionary<TKey,TValue> keeps an int[] buckets and an Entry[] entries array; colliding entries form a chain via a next index inside the entries array. When full, it resizes to roughly double (a prime size) and rehashes.
Contract: if a.Equals(b) then a.GetHashCode() == b.GetHashCode(), and a key's hash must not change while it is in the dictionary. Good hashes spread keys evenly.
Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.
How to tell it (STAR)
**S:** Our CRM [at company] was sending some customers the same campaign twice, and one complaint reached the privacy officer. **T:** I had to find the cause before the next weekly send. **A:** I reproduced it, found Equals was overridden without GetHashCode so Distinct never compared the emails, implemented equality properly with one case-insensitive comparer, and made the warning a build error. **R:** Duplicates stopped from the next send, and the database index now enforces uniqueness too.
Last updated 30 Oct 2026.