Skip to content

EF Core Interceptors

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.

The order interceptors are registered matters when they depend on each other’s outputs:

  1. SizeTripletSaveChangesInterceptor — Size calculations and cascading aggregations
  2. CurrencyConversionSaveChangesInterceptor — Currency conversion calculations
  3. CompletenessScoreInterceptor — Data completeness scores
  4. TimestampSaveChangesInterceptor — CreatedAt/UpdatedAt timestamps
  5. SpatialColumnUpdateInterceptor — Geometry column updates
  6. EnumCacheInvalidationInterceptor — Reference data cache invalidation
  7. AuditSaveChangesInterceptor — Audit trail logging

The size triplet interceptor is a thin orchestrator that delegates to three focused services. It runs four phases in sequence before each SaveChanges call.

SizeTripletSaveChangesInterceptor (orchestrator)
├── ISizeTripletCalculator → Phase 1: Local → Net/Gross conversion
├── ISizeAggregationService → Phase 2: cascading parent aggregation
└── IReductionCorrectionService → Phase 2b + 3: reduction sign correction

Files:

ClassFile
SizeTripletSaveChangesInterceptorData/Interceptors/SizeTripletSaveChangesInterceptor.cs
SizeTripletCalculatorServices/Conversion/SizeTripletCalculator.cs
SizeAggregationServiceServices/Conversion/SizeAggregationService.cs
ReductionCorrectionServiceServices/Conversion/ReductionCorrectionService.cs
EntityEntryValueHelpersData/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:

PropertyExample
{Prefix}LocalDevelopmentUseLocal, OccupierSizeLocal
{Prefix}NetDevelopmentUseNet, OccupierSizeNet
{Prefix}GrossDevelopmentUseGross, OccupierSizeGross
{Prefix}SizeUnitId or {Prefix}UnitIdDevelopmentUseSizeUnitId, OccupierSizeUnitId

When computation triggers:

  • Entity is newly added
  • Local value changed
  • SizeUnitId changed
  • Local is 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.DevelopmentSizeGross
DevelopmentUse.DevelopmentUseNet ──sum──▶ Development.DevelopmentSizeNet
Development.DevelopmentSizeGross ──sum──▶ Scheme.SchemeSizeGross
Development.DevelopmentSizeNet ──sum──▶ Scheme.SchemeSizeNet

Multi-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:

  1. 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.

  2. 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().

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:

  1. For each added/modified Development, check if the current sign matches the Reduction state.
  2. If mismatched, flip DevelopmentSizeGross and DevelopmentSizeNet.
  3. 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).
  4. 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:

MethodPurpose
ToNullableDecimalSafely converts object?decimal? across int, long, double, float
ToNullableIntSafely converts object?int? across int, long, short
SetValueType-safe property assignment on EntityEntry, handling decimal/double/float/int/long targets
DecimalScaleConstant 8 — the database column precision

Purpose: Automatically populate CreatedAt and UpdatedAt timestamp fields on all entities.

File: Data/Interceptors/TimestampSaveChangesInterceptor.cs

Rules:

  • Added: sets CreatedAt (if null/default) and UpdatedAt to UTC now. Marks CreatedAt as not modified to prevent overwrites.
  • Modified: sets UpdatedAt to UTC now. Marks CreatedAt as not modified to protect from accidental changes.

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 CompletenessScore and CompletenessScoreLastCalculated properties.

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 AuditLog and UserPreferences entities.
  • Records user identity (Azure AD Object ID, display name, email) from the HTTP context.

Purpose: Automatically convert currency values using a similar triplet pattern to the size interceptor.

File: Data/Interceptors/CurrencyConversionSaveChangesInterceptor.cs


Purpose: Invalidates the IEnumService cache when reference data entities are created, updated, or deleted.

File: Data/Interceptors/EnumCacheInvalidationInterceptor.cs


All interceptors materialise the change tracker entries list with .ToList() before processing to avoid “Collection was modified” exceptions.

Interceptors are registered as Singleton for DbContextPool compatibility. Scoped services (like ITripletConversionService) are resolved per-save via IServiceScopeFactory.

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.