Skip to main content

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:

  1. Write-only fields (e.g. ChoiceDto.ImageBase64/ImageName, mirrors CompanyDto.LogoBase64/LogoExtension) that would otherwise show a spurious "changed" every time a caller round-trips a null value.
  2. 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).