Apparently These Objects Are Equal Now

You know you’re dealing with a legacy codebase
when a table starts opening unrelated rows for editing.

Click one row.

Three others suddenly decide they would also like to participate.

This felt deeply wrong.

Mainly because they had absolutely nothing to do with each other.

At this point you begin questioning:

  • your code
  • the framework
  • your understanding of reality

As it turns out, the framework was innocent.

Reality was the thing that had been modified.

The Investigation


The table itself looked innocent. The state looked reasonable. The bindings looked reasonable. Even the framework looked… mostly reasonable.

Which was mildly disappointing because by that point I was hoping to find an obvious bug somewhere.

Instead, I started tracing the objects displayed by the table. Eventually I arrived at the model type behind the rows.


And then I found this:

public override bool Equals(object? obj) 
{ 
    return SomeProperty?.ToString() == ((MyType)obj)?.SomeProperty?.ToString(); 
}

This was the moment where experienced C# developers should start screaming.

The class was a reference type, yet equality had been redefined based on the string representation of a single property. Not a unique identifier. Not object identity. Not even a meaningful business key.

Just one property. Converted to a string.

If that value happened to match, the objects were considered equal.
And if that value happened to be an empty string?

Apparently half the universe was equal.

Rows representing completely different data suddenly belonged to the same existential category. At this point I finally understood why unrelated rows kept joining the editing session.

The framework wasn’t confused. The objects themselves had informed the framework that they were the same thing. Then I noticed something else.

There was no GetHashCode() implementation.
Which somehow wasn’t even the most alarming thing I had discovered.

Why This Should Make You Nervous


The equality implementation itself was already problematic.

Equality is not just another comparison function.
It’s a statement about identity.


When you override Equals(),
you’re telling the runtime which objects should be treated as the same thing.

For a reference type, that’s a surprisingly important decision.

In many ways, equality forms part of the foundation everything else is built on.
Collections use it. LINQ uses it. Frameworks use it.
Whenever .NET needs to answer the seemingly simple question:

“Are these two things the same?”

It eventually ends up relying on your answer.

Which means that redefining equality isn’t a local change.

You’re modifying one of the assumptions the rest of the ecosystem quietly depends on.

In this case, two completely different objects could become equal because a single property happened to produce the same string. Sometimes that string wasn’t even particularly meaningful.
In some cases it was empty.

This was not a useful definition of identity.

It was merely a definition.

The missing GetHashCode() implementation made things even worse.

In .NET, Equals() and GetHashCode() form a contract.
If two objects are considered equal, they must produce the same hash code.

Always.

Not because the framework designers enjoy inventing rules, but because large parts of .NET depend on that assumption being true.


The framework needs a reliable way to determine whether two objects represent the same thing. Once that assumption stops being reliable, the behavior of everything built on top of it becomes considerably more exciting.

The Haunted House


Once you’ve broken equality, different parts of .NET start developing different opinions about reality.

This is where things become interesting.

Or concerning.

Once equality stops behaving sensibly,
you enter what I like to call the haunted house phase.

The unpleasant part is that not everything breaks.

That would be convenient.

Instead, some things work perfectly fine. Some things fail. Some things only fail under very specific circumstances. Whether a bug appears can suddenly depend on implementation details of a collection you weren’t even thinking about.

  • A HashSet<T> can end up containing multiple objects that are supposedly equal.
  • A Dictionary<TKey, TValue> may fail to find an object that appears to exist.
  • A LINQ query may happily tell you two objects are equal while a completely different piece of code treats them as unrelated.
  • You can check whether a collection contains an object and then immediately fail to retrieve it.
  • You can remove an object from a collection and discover that the collection disagrees about which object you meant.
  • And of course you can confuse a UI framework that is trying to determine which row represents which piece of data.

Hypothetically speaking.

The particularly maddening part is that these problems are often intermittent. They depend on things like collection internals, hash bucket distribution, object ordering, or code paths you never intended to care about.

The result is a category of bugs that feel less like software engineering and more like paranormal investigation. At this point you’re no longer debugging business logic.
You’re debugging a universe where different parts of the runtime have developed slightly different opinions about what “the same object” means.

The Fix


At this point I was hoping there was a really good reason for all of this.

And before we continue: never delete code simply because it looks insane.

Insane code often exists for a reason. Before removing something, check the codebase, the git history, documentation, or preferably the person who wrote it. Deeply specific logic is often protecting deeply specific business requirements.

As it turns out, that was true here as well.

The comparison logic was used in a multi-user editing scenario to detect configuration changes while another user was editing the same data and prevent updates from being lost.

A perfectly reasonable requirement.

The problem wasn’t the comparison itself.

The problem was that this comparison had become the universal definition of equality for the entire type. That behavior was needed in exactly one place.

The solution was a helper function.

So the custom equality implementation was removed and replaced with an explicit comparison method used only where that behavior was actually required.

The table stopped inviting unrelated rows into the editing session.
The framework was vindicated.
And object identity once again became a matter of fact rather than opinion.