Lightweight C# source generator that produces efficient, allocation‑friendly diffing code for your aggregate / entity graphs. You declare what collections are keyed (identity) using a tiny fluent DSL (ChangeTracking.Map + TrackBy). The generator emits strongly‑typed GetDifferences extension methods that walk two equal object hierarchies (original + modified) and yield a normalized stream of Difference objects.
Zero reflection at runtime. No expression tree compilation. Pure compile‑time generation + straightforward POCO iteration.
This project gives you:
- Deterministic paths (
Person.Addresses[12345].Street) enabling filtering / dispatch. - Identity-aware collection diffing via
TrackBy(x => x.Key)– stable regardless of ordering. - Recursive scalar + complex property comparison with early null short‑circuit.
- Composable post-processing (regex‑based
IDifferenceHandler& dispatcher included). - Simple primitives detection (no accidental deep walk of value objects like
Guid).
dotnet add package MathMax.Generators.ChangeTrackingImportant: You must also manually add the runtime package:
dotnet add package MathMax.ChangeTrackingThe generator package does not automatically include the runtime package due to source generator packaging constraints.
- In any
.csfile, declare a mapping describing which collections are keyed:
using MathMax.ChangeTracking;
using Your.Domain.Models;
public static class ChangeTrackingConfig
{
static ChangeTrackingConfig()
{
ChangeTracking.Map<Person>(p =>
{
p.Addresses.TrackBy(a => new { a.ZipCode, a.HouseNumber }); // composite key
p.Orders.TrackBy(o => o.OrderId, o =>
{
// Nested keyed collection inside Order
o.Items.TrackBy(i => i.ProductId);
});
});
}
}- Build the solution. A generated file (e.g.
Person.ChangeTracking.g.cs) appears in Analyzers → Generated Files. - Use the generated extension to diff two graphs:
IEnumerable<Difference> diffs = modifiedPerson.GetDifferences(originalPerson);
foreach (var d in diffs)
Console.WriteLine(d); // Person.FirstName: John -> Jonathanvar original = new Person { FirstName = "John", LastName = "Smith", PersonId = Guid.NewGuid() };
var modified = new Person { FirstName = "Jonathan", LastName = "Smith", PersonId = original.PersonId };
ChangeTracking.Map<Person>(p => { /* even empty map will still compare scalars */ });
var diffs = modified.GetDifferences(original); // generated after build
// Yields: Person.FirstName differenceTypical change tracking for nested aggregates (Orders → Items, Person → Addresses, etc.) either:
- Re‑implements ad‑hoc recursive comparison logic per model.
- Uses heavy reflection / JSON diff libs with extra boxing & allocations.
- Fails to treat collections by identity (so reorder = massive diff noise).
| Project | Purpose | Target |
|---|---|---|
MathMax.Generators.ChangeTracking |
Source generator (adds *.ChangeTracking.g.cs) |
netstandard2.0 (Roslyn) |
MathMax.ChangeTracking |
Runtime helper types (Difference, dispatcher, DSL markers) |
net9.0 |
MathMax.ChangeTracking.Examples |
Example POCOs + hand-crafted sample | net9.0 |
ChangeTracking.Map<TRoot>(lambda) is a compile‑time marker. The lambda executes at runtime with default! so avoid dereferencing – only call TrackBy on collection navigation properties.
collection.TrackBy(x => x.Key, x => { /* nested TrackBy for child collections */ })
- First lambda chooses the identity key (can be an anonymous type for composite keys).
- Optional second lambda describes nested keyed collections of the element type.
No runtime data structures are stored – the generator inspects the syntax tree.
For a root entity Person a static internal class PersonChangeTrackerGenerated is produced with:
public static IEnumerable<Difference> GetDifferences(this Person right, Person left, string path = nameof(Person))- Private overloads for each involved complex type encountered.
- Inline scalar comparisons:
if (left.FirstName != right.FirstName) yield return ... - Collection diff loops invoking
DiffListByIdentitywith your key selector expression text.
- Scalar property:
Person.FirstName - Complex nested:
Person.Address.City - Collection element:
Person.Addresses[ZIP_HOUSE](whereZIP_HOUSEis the projected key – for anonymous composite keys theToString()of the anonymous instance is used). - Property of a collection element:
Person.Orders[42].Items[5001].Quantity
Difference contains:
Path– unique descriptor usable for matching/regex.LeftOwner/RightOwner– owning object instance (null if object added/removed entirely).LeftValue/RightValue– the property / presence values.
- Presence (added/removed):
LeftValueorRightValuewill be null andPathpoints to collection slot (Orders[123]). - Property:
Pathincludes.PropertyName.
Implement IDifferenceHandler<TModel,TEntity> with a Regex PathPattern to react to selected changes (e.g., update denormalized read model, emit events):
public class PersonNameChangedHandler : IDifferenceHandler<Person, Person>
{
public Regex PathPattern { get; } = new("^Person\\.(FirstName|LastName)$", RegexOptions.Compiled);
public void Handle(Difference diff, Match match, Person original, Person altered, Person entity)
{
// e.g. mark entity as needing re-indexing
}
}
var dispatcher = new DifferenceDispatcher<Person, Person>(new IDifferenceHandler<Person, Person>[] { new PersonNameChangedHandler() });
var result = dispatcher.Dispatch(diffs, originalPerson, modifiedPerson, modifiedPerson);
Console.WriteLine($"Handled: {result.Handled.Length}, Unhandled: {result.Unhandled.Length}");- Single pass over each collection (no sorting or double enumeration).
- Keys resolved once; dictionary lookups O(1) for presence.
- Only allocs:
Differenceobjects + ephemeral enumerators. - No reflection per element; generator emits strongly typed property access.
- Execute
Mapconfiguration once at startup (static ctor pattern); duplicate maps are de‑duplicated by generation grouping. - Do not rely on execution order inside the lambda – only the presence of
TrackBycalls matters. - Anonymous composite keys should be stable (avoid floating-point parts prone to precision changes).
- Nullable reference scalar comparisons rely on standard
!=semantics; override equality for custom structs if needed. - Currently only one
TrackByper distinct(OwnerType, CollectionProperty)in output (duplicates collapsed).
- NuGet packaging & version badge.
- Optionally generate diff direction
left vs rightparameter order consistency (currentlyright, left). - Configurable path escaping for keys containing
]or.. - Pluggable value comparers (e.g., case-insensitive strings, tolerance for decimals).
- Generation of specialized handlers or strongly‑typed events.
- Benchmarks project (BenchmarkDotNet) documenting throughput & allocation vs reflection libs.
See CONTRIBUTING.md for detailed contribution guidelines including development setup, pull request process, and coding standards.
MIT (see LICENSE).
Feel free to open an issue if a desired scenario (e.g., dictionaries, value object custom equality, ignoring properties) is missing.