Compares the data of two object
$ dotnet add package ObjectComparerA flexible .NET library for comparing two objects of the same type and extracting differences based on configurable rules.
ObjectComparer allows you to define custom comparison rules for objects and their nested collections. Instead of prescribing specific difference types, the library is fully generic - you define your own difference classes that fit your domain needs.
Key Features:
dotnet add package ObjectComparer
Or via NuGet Package Manager:
Install-Package ObjectComparer
using ObjectComparer;
// Define your difference type (interface or abstract class)
public interface IDifference { }
public class NameChanged : IDifference
{
public string OldName { get; }
public string NewName { get; }
public NameChanged(string oldName, string newName)
{
OldName = oldName;
NewName = newName;
}
}
// Create a comparer
IComparer<Person, IDifference> comparer = new Comparer<Person, IDifference>();
// Add comparison rules
comparer.AddRule(
condition: (source, target) => source.Name != target.Name,
differenceFactory: (source, target) => new NameChanged(source.Name, target.Name));
// Compare objects
var person1 = new Person { Name = "John" };
var person2 = new Person { Name = "Jane" };
IDifference[] differences = comparer.Compare(person1, person2);
Use AddRule() to compare simple properties:
comparer.AddRule(
condition: (source, target) => source.Age != target.Age,
differenceFactory: (source, target) => new AgeChanged(source.Age, target.Age));
comparer.AddRule(
condition: (source, target) => source.Email != target.Email,
differenceFactory: (source, target) => new EmailChanged(source.Email, target.Email));
Rules are evaluated in order and multiple rules can produce differences for the same comparison.
ObjectComparer provides two approaches for comparing collections, each optimized for different scenarios:
Use when items have unique keys (like Id properties). This approach uses dictionary-based lookups for O(n) performance.
comparer.AddRuleForEach(
itemsSelector: person => person.Addresses,
keySelector: address => address.Id, // Must be unique!
addedFactory: (source, target, added) => new AddressAdded(added.Id, added.City),
removedFactory: (source, target, removed) => new AddressRemoved(removed.Id),
configureComparer: itemComparer => itemComparer
.AddRule(
condition: (sourceAddr, targetAddr) => sourceAddr.City != targetAddr.City,
differenceFactory: (sourceAddr, targetAddr) =>
new AddressCityChanged(sourceAddr.Id, sourceAddr.City, targetAddr.City)));
Performance: O(n) - uses dictionaries for efficient lookups
Requirement: Keys must be unique within each collection (throws ArgumentException if duplicates found)
Best for: Collections with natural unique identifiers (Id, Key, Code, etc.)
Use when matching requires complex logic beyond simple key equality. This approach uses O(n²) performance but supports any matching logic.
comparer.AddRuleForEach(
itemsSelector: person => person.Addresses,
matchingPredicate: (sourceAddr, targetAddr) =>
sourceAddr.Id == targetAddr.Id, // Can be any complex logic
addedFactory: (source, target, added) => new AddressAdded(added.Id, added.City),
removedFactory: (source, target, removed) => new AddressRemoved(removed.Id),
configureComparer: itemComparer => itemComparer
.AddRule(
condition: (sourceAddr, targetAddr) => sourceAddr.City != targetAddr.City,
differenceFactory: (sourceAddr, targetAddr) =>
new AddressCityChanged(sourceAddr.Id, sourceAddr.City, targetAddr.City)));
Performance: O(n²) - iterates through items to find matches
Requirement: Predicate must uniquely identify items (throws InvalidOperationException if multiple matches found)
Best for:
(s, t) => s.FirstName == t.FirstName && s.LastName == t.LastName(s, t) => s.Name.Equals(t.Name, StringComparison.OrdinalIgnoreCase)The configureComparer parameter allows recursive comparison of matched items:
comparer.AddRuleForEach(
itemsSelector: company => company.Departments,
keySelector: dept => dept.Id,
configureComparer: deptComparer => deptComparer
// Compare department properties
.AddRule(
condition: (s, t) => s.Name != t.Name,
differenceFactory: (s, t) => new DepartmentNameChanged(s.Name, t.Name))
// Recursively compare employees within each department
.AddRuleForEach(
itemsSelector: dept => dept.Employees,
keySelector: emp => emp.Id,
configureComparer: empComparer => empComparer
.AddRule(
condition: (s, t) => s.Salary != t.Salary,
differenceFactory: (s, t) => new SalaryChanged(s.Id, s.Salary, t.Salary))));
using ObjectComparer;
public class Person
{
public string Name { get; set; } = string.Empty;
public int Age { get; set; }
public Address[] Addresses { get; set; } = Array.Empty<Address>();
}
public class Address
{
public int Id { get; set; }
public string City { get; set; } = string.Empty;
}
// Define your difference types
public interface IDifference { }
public class NameChanged : IDifference
{
public string OldName { get; }
public string NewName { get; }
public NameChanged(string oldName, string newName)
{
OldName = oldName;
NewName = newName;
}
}
public class AgeChanged : IDifference
{
public int OldAge { get; }
public int NewAge { get; }
public AgeChanged(int oldAge, int newAge)
{
OldAge = oldAge;
NewAge = newAge;
}
}
public class AddressAdded : IDifference
{
public int AddressId { get; }
public string City { get; }
public AddressAdded(int addressId, string city)
{
AddressId = addressId;
City = city;
}
}
public class AddressRemoved : IDifference
{
public int AddressId { get; }
public AddressRemoved(int addressId)
{
AddressId = addressId;
}
}
public class AddressCityChanged : IDifference
{
public int AddressId { get; }
public string OldCity { get; }
public string NewCity { get; }
public AddressCityChanged(int addressId, string oldCity, string newCity)
{
AddressId = addressId;
OldCity = oldCity;
NewCity = newCity;
}
}
// Usage
class Program
{
static void Main()
{
var person1 = new Person
{
Name = "John",
Age = 30,
Addresses = new[]
{
new Address { Id = 1, City = "New York" },
new Address { Id = 2, City = "Boston" }
}
};
var person2 = new Person
{
Name = "John",
Age = 31,
Addresses = new[]
{
new Address { Id = 1, City = "Los Angeles" },
new Address { Id = 3, City = "Chicago" }
}
};
// Create comparer with rules
IComparer<Person, IDifference> comparer = new Comparer<Person, IDifference>();
comparer.AddRule(
condition: (source, target) => source.Name != target.Name,
differenceFactory: (source, target) => new NameChanged(source.Name, target.Name));
comparer.AddRule(
condition: (source, target) => source.Age != target.Age,
differenceFactory: (source, target) => new AgeChanged(source.Age, target.Age));
// Use key-based matching for O(n) performance
comparer.AddRuleForEach(
itemsSelector: person => person.Addresses,
keySelector: address => address.Id,
addedFactory: (source, target, added) => new AddressAdded(added.Id, added.City),
removedFactory: (source, target, removed) => new AddressRemoved(removed.Id),
configureComparer: itemComparer => itemComparer
.AddRule(
condition: (sourceAddr, targetAddr) => sourceAddr.City != targetAddr.City,
differenceFactory: (sourceAddr, targetAddr) =>
new AddressCityChanged(sourceAddr.Id, sourceAddr.City, targetAddr.City)));
// Compare
IDifference[] differences = comparer.Compare(person1, person2);
// Results:
// - AgeChanged(30, 31)
// - AddressRemoved(2)
// - AddressAdded(3, "Chicago")
// - AddressCityChanged(1, "New York", "Los Angeles")
}
}
| Collection Size | Key-Based (keySelector) | Predicate-Based (matchingPredicate) |
|---|---|---|
| 10 items | ~instant | ~instant |
| 100 items | ~instant | ~instant |
| 1,000 items | < 1ms | ~10ms |
| 10,000 items | ~10ms | ~1000ms (1 second) |
Recommendation: Use keySelector whenever possible. Only use matchingPredicate when you need complex matching logic that cannot be expressed as a simple key.
# Build the solution
dotnet build ObjectComparer.sln
# Run tests
dotnet test
# Run tests with coverage
dotnet test /p:CollectCoverage=true
# Run the demo workbench
dotnet run --project Workbench/Workbench.csproj
# Pack for NuGet
dotnet pack ObjectComparer/ObjectComparer.csproj -c Release
The library uses a rule-based comparison pattern:
Comparer<TType, TDiff> instanceAddRule() and AddRuleForEach()IRule interfaceCompare(), all rules are executed in orderThe Comparer class is split across multiple partial class files:
Comparer.cs - Main APIComparer.Rule.cs - Simple property comparison rulesComparer.RuleForEach.cs - Predicate-based collection rules (O(n²))Comparer.RuleForEachWithKey.cs - Key-based collection rules (O(n))Comparer.IRule.cs - Internal rule interfaceMIT License - see LICENSE file for details
Alkiviadis Skoutaris
https://github.com/askoutaris/object-comparer
Contributions are welcome! Please feel free to submit a Pull Request.