EF Core Interceptors
EF Core Interceptors
Section titled “EF Core Interceptors”Overview
Section titled “Overview”Entity Framework Core interceptors hook into the SavingChangesAsync pipeline to automatically perform calculations, validations, and logging before data is persisted to the database. All interceptors inherit from SaveChangesInterceptor.
Interceptor Execution Order
Section titled “Interceptor Execution Order”The order interceptors are registered matters when they depend on each other’s outputs:
- SizeTripletSaveChangesInterceptor — Size calculations and cascading aggregations
- CurrencyConversionSaveChangesInterceptor — Currency conversion calculations
- CompletenessScoreInterceptor — Data completeness scores
- TimestampSaveChangesInterceptor — CreatedAt/UpdatedAt timestamps
- SpatialColumnUpdateInterceptor — Geometry column updates
- EnumCacheInvalidationInterceptor — Reference data cache invalidation
- AuditSaveChangesInterceptor — Audit trail logging
Size Triplet Pipeline
Section titled “Size Triplet Pipeline”The size triplet interceptor is a thin orchestrator that delegates to three focused services. It runs four phases in sequence before each SaveChanges call.
Architecture
Section titled “Architecture”SizeTripletSaveChangesInterceptor (orchestrator)├── ISizeTripletCalculator → Phase 1: Local → Net/Gross conversion├── ISizeAggregationService → Phase 2: cascading parent aggregation└── IReductionCorrectionService → Phase 2b + 3: reduction sign correctionFiles:
| Class | File |
|---|---|
SizeTripletSaveChangesInterceptor | Data/Interceptors/SizeTripletSaveChangesInterceptor.cs |
SizeTripletCalculator | Services/Conversion/SizeTripletCalculator.cs |
SizeAggregationService | Services/Conversion/SizeAggregationService.cs |
ReductionCorrectionService | Services/Conversion/ReductionCorrectionService.cs |
EntityEntryValueHelpers | Data/Interceptors/EntityEntryValueHelpers.cs |
Phase 1 — Triplet Calculation (ISizeTripletCalculator)
Section titled “Phase 1 — Triplet Calculation (ISizeTripletCalculator)”Converts user-entered Local values to Net and Gross using the selected size unit.
Property naming convention: the calculator discovers triplets by reflection, matching properties that follow the pattern:
| Property | Example |
|---|---|
{Prefix}Local | DevelopmentUseLocal, OccupierSizeLocal |
{Prefix}Net | DevelopmentUseNet, OccupierSizeNet |
{Prefix}Gross | DevelopmentUseGross, OccupierSizeGross |
{Prefix}SizeUnitId or {Prefix}UnitId | DevelopmentUseSizeUnitId, OccupierSizeUnitId |
When computation triggers:
- Entity is newly added
Localvalue changedSizeUnitIdchangedLocalis non-null but Net or Gross is null (repair)
Null-in, null-out: if Local or SizeUnitId is null, Net and Gross are cleared to null.
Conversion context: the calculator resolves BuildingTypeId and CountryId via entity-specific hierarchy resolvers (e.g., DevelopmentUseHierarchyResolver walks DevelopmentUse → Development → Scheme → Address to find CountryId). These are passed to ITripletConversionService which looks up the appropriate scaler records.
Precision: all results are rounded to 8 decimal places to match the database column definition DECIMAL(18, 8).
Phase 2 — Parent Aggregation (ISizeAggregationService)
Section titled “Phase 2 — Parent Aggregation (ISizeAggregationService)”Cascades child values up the entity hierarchy using [AggregatesFrom] attributes.
Hierarchy:
DevelopmentUse.DevelopmentUseGross ──sum──▶ Development.DevelopmentSizeGrossDevelopmentUse.DevelopmentUseNet ──sum──▶ Development.DevelopmentSizeNetDevelopment.DevelopmentSizeGross ──sum──▶ Scheme.SchemeSizeGrossDevelopment.DevelopmentSizeNet ──sum──▶ Scheme.SchemeSizeNetMulti-pass processing: runs up to 5 passes. Each pass processes one level of the hierarchy. Stops early when no updates occur. This handles the full DevelopmentUse → Development → Scheme cascade.
Child resolution: for each parent, children are collected from both the change tracker (with in-flight values) and the database (for persisted children not in the tracker). Change-tracker values take priority. Deleted children are excluded.
Aggregation functions: Sum (default), Count, and Average, as specified by the [AggregatesFrom] attribute’s AggregationFunction property.
Reduction business rules during aggregation
Section titled “Reduction business rules during aggregation”Two business rules apply when aggregating into parent entities:
-
Reduction sign negation (Development level): when a Development’s type is Reduction (code
A.05), aggregated sizes from its DevelopmentUse children are stored as negative values on the Development. This ensures they subtract from the parent Scheme total. Clients always submit positive Local values; the sign flip is automatic. -
Reduction status filtering (Scheme level): Reduction developments are excluded from Scheme totals unless their status qualifies:
- Included: Under Construction (
D), Complete (E) - Excluded: all other statuses (e.g., Planned, Withdrawn)
This is governed by
DevelopmentAggregationRules.IsReductionStatusIncluded(). - Included: Under Construction (
Phase 2b — Use Sign Correction (IReductionCorrectionService.ApplyUseSignCorrection)
Section titled “Phase 2b — Use Sign Correction (IReductionCorrectionService.ApplyUseSignCorrection)”After aggregation, individual DevelopmentUse sizes (Local, Net, Gross) are negated when the parent Development is a Reduction type. This ensures the API returns consistent negative values at every level — not just on the aggregated Development totals.
Example: a DevelopmentUse with Local = 100, Net = 92.9, Gross = 139.35 under a Reduction Development becomes Local = -100, Net = -92.9, Gross = -139.35.
Phase 3 — Development Sign Correction (IReductionCorrectionService.ApplyDevelopmentSignCorrection)
Section titled “Phase 3 — Development Sign Correction (IReductionCorrectionService.ApplyDevelopmentSignCorrection)”Handles the edge case where DevelopmentTypeId is patched to/from Reduction without any child DevelopmentUse changes. In this scenario, Phase 2 does not re-aggregate (because no children changed), so the Development’s DevelopmentSizeGross and DevelopmentSizeNet may have the wrong sign.
Phase 3 steps:
- For each added/modified Development, check if the current sign matches the Reduction state.
- If mismatched, flip
DevelopmentSizeGrossandDevelopmentSizeNet. - Load all persisted DevelopmentUse rows from the database and flip their signs too (they are not in the change tracker for a type-only patch).
- Re-run one aggregation pass so the parent Scheme totals reflect the corrected values.
Shared Utilities (EntityEntryValueHelpers)
Section titled “Shared Utilities (EntityEntryValueHelpers)”Static helper class used by all three services:
| Method | Purpose |
|---|---|
ToNullableDecimal | Safely converts object? → decimal? across int, long, double, float |
ToNullableInt | Safely converts object? → int? across int, long, short |
SetValue | Type-safe property assignment on EntityEntry, handling decimal/double/float/int/long targets |
DecimalScale | Constant 8 — the database column precision |
TimestampSaveChangesInterceptor
Section titled “TimestampSaveChangesInterceptor”Purpose: Automatically populate CreatedAt and UpdatedAt timestamp fields on all entities.
File: Data/Interceptors/TimestampSaveChangesInterceptor.cs
Rules:
- Added: sets
CreatedAt(if null/default) andUpdatedAtto UTC now. MarksCreatedAtas not modified to prevent overwrites. - Modified: sets
UpdatedAtto UTC now. MarksCreatedAtas not modified to protect from accidental changes.
CompletenessScoreInterceptor
Section titled “CompletenessScoreInterceptor”Purpose: Automatically calculate data completeness scores for entities implementing IHasCompletenessScore.
File: Data/Interceptors/CompletenessScoreInterceptor.cs
Rules:
- Recalculates when entity is Added, or when a property marked with
[CompletenessScore(points)]is modified. - Updates
CompletenessScoreandCompletenessScoreLastCalculatedproperties.
AuditSaveChangesInterceptor
Section titled “AuditSaveChangesInterceptor”Purpose: Log all create, update, and delete operations for audit trail and compliance.
File: Data/Interceptors/AuditSaveChangesInterceptor.cs
Rules:
- Captures all property values for Added entities, only modified properties for Modified entities, and all properties for Deleted entities.
- Excludes
AuditLogandUserPreferencesentities. - Records user identity (Azure AD Object ID, display name, email) from the HTTP context.
CurrencyConversionSaveChangesInterceptor
Section titled “CurrencyConversionSaveChangesInterceptor”Purpose: Automatically convert currency values using a similar triplet pattern to the size interceptor.
File: Data/Interceptors/CurrencyConversionSaveChangesInterceptor.cs
EnumCacheInvalidationInterceptor
Section titled “EnumCacheInvalidationInterceptor”Purpose: Invalidates the IEnumService cache when reference data entities are created, updated, or deleted.
File: Data/Interceptors/EnumCacheInvalidationInterceptor.cs
Common Patterns
Section titled “Common Patterns”Materialisation
Section titled “Materialisation”All interceptors materialise the change tracker entries list with .ToList() before processing to avoid “Collection was modified” exceptions.
DI Lifetime
Section titled “DI Lifetime”Interceptors are registered as Singleton for DbContextPool compatibility. Scoped services (like ITripletConversionService) are resolved per-save via IServiceScopeFactory.
IsModified Flag
Section titled “IsModified Flag”When updating properties programmatically, always set IsModified = true so EF Core includes the column in the generated SQL. For protected properties (like CreatedAt), set IsModified = false to prevent updates.