Audit-trail change detection: DtoDiff vs. EF Core's ChangeTracker
Every Update should know which fields actually changed, not just that a
write happened — this rides along on the eventing pattern (see the Eventing
Pattern page) as DomainEvent.Changes
(IReadOnlyList<PropertyChange>?, null for Created/Deleted — nothing to
diff against).
Two different mechanisms, chosen by how the entity writes, not by preference:
SqlCommand/legacy-stored-procedure entities — DtoDiff
City, and everything that will follow its shape, have no EF Core change
tracking in the write path at all — the DTO is just a parameter bag for the
SP call. For these, UpdateAsync fetches the row's prior state first (e.g.
CityRepository.UpdateAsync calls its own GetByCodeAsync before building
the SP payload), then Application/Common/DtoDiff.cs
(DtoDiff.Compare<T>(before, after)) reflects over the DTO's public
properties and emits a PropertyChange for each one that differs.
Plain-EF-Core-write entities — EF Core's own ChangeTracker
Choices (the only one so far — see the Reference CRUD Pattern's SP-exception
note) already has a tracked entity sitting in the DbContext — reading
EF Core's own ChangeTracker
(context.Entry(entity).Properties.Where(p => p.IsModified), see
ChoiceRepository.BuildChanges) is simpler and more accurate than diffing
DTOs by hand, since EF already knows exactly what changed.
:::caution Never use DtoDiff for an EF-tracked entity That would be redoing work EF already does for free, less reliably. :::
[AuditIgnore]
Application/Common/AuditIgnoreAttribute.cs marks a DTO property as
excluded from DtoDiff's reflection — for two distinct reasons:
- Write-only fields (e.g.
ChoiceDto.ImageBase64/ImageName, mirrorsCompanyDto.LogoBase64/LogoExtension) that would otherwise show a spurious "changed" every time a caller round-trips a null value. - Fields a repository's own logging rule already restricts from ever
appearing in output (e.g.
CityDto.Latitude/Longitude/the manifest-doc-path fields — see the Reference CRUD Pattern's "never log lat/long, manifest doc paths, ... actor fields" rule).
UpdatedBy-style actor fields are excluded on both paths for the same
reason plus one more: they're already carried separately as
DomainEvent.Actor, and they change on every write regardless of what else
did, so diffing them is pure noise, not signal.
Scope
:::note Code-level building block only, not a persisted audit log yet
Changes currently only ever reaches a RabbitMQ message body (via the
existing eventing pipeline, itself still publish-only). Where audit history
actually gets stored/queried from is a separate, not-yet-scoped decision —
see TASKS.md's "Implement audit trails, incrementally" item, which also
notes that an audit-trail consumer is a natural candidate for the first real
IFTTT consumer.
:::
Reference files
PropertyChange.cs, AuditIgnoreAttribute.cs, DtoDiff.cs,
CityRepository.cs (SqlCommand/DtoDiff path), ChoiceRepository.cs (EF
Core/ChangeTracker path).