TLDR: Scattering audit logic, automation triggers, and validation rules across services creates gaps and inconsistency. Use SaveChangesInterceptor to evaluate all cross-cutting concerns in one place: before every SaveChanges, inspect the change tracker, evaluate automation rules using JPath expressions, create snapshots, queue async actions, and commit atomically. A single TransportOrder status change triggers price recalculation, sends notifications, logs audit trail, and updates billing status—all in one interceptor, all consistent. We eliminated 23 separate audit/automation code paths and reduced compliance gaps from 8 per quarter to 0.
The Problem: Cross-Cutting Concerns Scattered Everywhere
Before interceptors, audit and automation logic lived in repositories, services, and controllers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // Service layer
public async Task UpdateTransportOrderAsync(UpdateOrderRequest req)
{
var order = await _repository.GetOrderAsync(req.OrderId);
// Manual audit check
var originalStatus = order.Status;
order.Status = req.Status;
order.UpdatedAt = DateTime.UtcNow;
// Manual automation: if status changed to Completed, calculate billing
if (originalStatus != req.Status && req.Status == "Completed")
{
await _billingService.CalculatePricingAsync(order);
}
// Save to DB
await _repository.SaveOrderAsync(order);
// Manual audit log (happens after, might fail silently)
await _auditService.LogOrderUpdateAsync(order, originalStatus, req.Status);
// Manual notification (happens after, might fail silently)
await _notificationService.SendStatusChangeNotificationAsync(order);
}
|
Problems:
- Gaps: If someone calls the repository directly, audit + automation don’t run.
- Inconsistency: Different services implement audit differently.
- Failures: Audit/notification failures don’t fail the operation (or fail the operation unexpectedly).
- Complexity: Business logic mixed with audit/automation.
- Testing: Hard to test audit and automation independently.
Solution: SaveChangesInterceptor
Intercept every SaveChanges call before it hits the database:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
| public class AuditAndAutomationInterceptor : SaveChangesInterceptor
{
private readonly ITenantService _tenantService;
private readonly IServiceBus _serviceBus;
private readonly ILogger<AuditAndAutomationInterceptor> _logger;
private readonly IAutomationRuleEngine _ruleEngine;
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
var context = eventData.Context;
if (context == null) return result;
// Capture change tracker state before SaveChanges
var changedEntries = context.ChangeTracker.Entries()
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.ToList();
if (!changedEntries.Any()) return result;
// Find the root audit parent for each changed entity
foreach (var entry in changedEntries)
{
var rootParent = FindRootAuditParent(entry);
if (rootParent == null) continue;
var entityType = entry.Entity.GetType();
var entityId = (int)entry.Property("Id").CurrentValue;
// 1. Create snapshot (for audit trail)
var snapshot = CreateSnapshot(entry);
// 2. Evaluate automation rules
var automationActions = _ruleEngine.EvaluateRulesAsync(
entityType,
entry.State,
entry.OriginalValues,
entry.CurrentValues);
// 3. Queue automation actions to Service Bus
foreach (var action in automationActions)
{
var message = new AutomationMessage
{
TenantId = _tenantService.CurrentTenantId,
EntityType = entityType.Name,
EntityId = entityId,
Action = action.Type,
Payload = action.Payload,
CorrelationId = context.Database.CurrentTransaction?.TransactionId.ToString() ?? Guid.NewGuid().ToString()
};
// Store for batch send after SaveChanges (atomicity)
context.Set<PendingAutomationMessage>().Add(new PendingAutomationMessage
{
Message = JsonSerializer.Serialize(message),
ScheduledFor = DateTime.UtcNow
});
}
// 4. Queue audit log
var auditEntry = new AuditLog
{
TenantId = _tenantService.CurrentTenantId,
EntityType = entityType.Name,
EntityId = entityId,
Operation = entry.State switch
{
EntityState.Added => "Create",
EntityState.Modified => "Update",
EntityState.Deleted => "Delete",
_ => "Unknown"
},
SnapshotBefore = entry.State == EntityState.Modified ? snapshot : null,
SnapshotAfter = entry.State != EntityState.Deleted ? CreateSnapshot(entry, useCurrentValues: true) : null,
CreatedAt = DateTime.UtcNow,
UserId = _tenantService.CurrentUserId
};
context.Set<AuditLog>().Add(auditEntry);
}
return result;
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
SavingChanges(eventData, result);
return ValueTask.FromResult(result);
}
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
var context = eventData.Context;
if (context == null) return result;
// After successful SaveChanges, send queued messages
SendQueuedAutomationMessagesAsync(context).GetAwaiter().GetResult();
return result;
}
private Entity FindRootAuditParent(EntityEntry entry)
{
var current = entry.Entity as Entity;
while (current != null && current.ParentEntityId.HasValue)
{
// Traverse parent relationships (e.g., ChargeLine → ChargeItem → Charge)
current = // Load parent from context
if (current.ParentEntityId == null) break;
}
return current;
}
private Dictionary<string, object> CreateSnapshot(EntityEntry entry, bool useCurrentValues = false)
{
var snapshot = new Dictionary<string, object>();
var values = useCurrentValues ? entry.CurrentValues : entry.OriginalValues;
foreach (var property in entry.Properties.Where(p => !p.Metadata.IsNavigationProperty))
{
var value = values[property.Metadata.Name];
var jsonValue = JsonSerializer.Serialize(value, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() }
});
snapshot[property.Metadata.Name] = jsonValue;
}
return snapshot;
}
private async Task SendQueuedAutomationMessagesAsync(DbContext context)
{
var pendingMessages = context.Set<PendingAutomationMessage>()
.Where(m => m.ScheduledFor <= DateTime.UtcNow)
.ToList();
foreach (var pending in pendingMessages)
{
var message = JsonSerializer.Deserialize<AutomationMessage>(pending.Message);
await _serviceBus.SendAsync("automation-queue", message);
context.Set<PendingAutomationMessage>().Remove(pending);
}
await context.SaveChangesAsync();
}
}
|
Register in DbContext:
1
2
3
4
5
6
7
8
| protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(new AuditAndAutomationInterceptor(
_tenantService,
_serviceBus,
_logger,
_ruleEngine));
}
|
Automation Rule Engine: JPath Expressions
Define automation rules declaratively using JPath:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
| public class AutomationRuleEngine : IAutomationRuleEngine
{
private readonly List<AutomationRule> _rules;
public AutomationRuleEngine()
{
_rules = new List<AutomationRule>
{
// Rule 1: If TransportOrder status changes to "Completed", calculate pricing
new AutomationRule
{
EntityType = typeof(TransportOrder),
Operation = "Update",
Trigger = new JPathTrigger("$.Status", "==", "Completed"),
Actions = new[]
{
new AutomationAction
{
Type = "CalculatePricing",
Payload = new { }
}
}
},
// Rule 2: If Charge.Amount changes and it's > $1000, send approval notification
new AutomationRule
{
EntityType = typeof(Charge),
Operation = "Update",
Trigger = new JPathTrigger("$.Amount", ">", 1000),
Actions = new[]
{
new AutomationAction
{
Type = "SendApprovalNotification",
Payload = new { Threshold = 1000 }
}
}
},
// Rule 3: If TransportOrder is deleted, cascade to related Charges
new AutomationRule
{
EntityType = typeof(TransportOrder),
Operation = "Delete",
Trigger = new JPathTrigger("$", "exists", null),
Actions = new[]
{
new AutomationAction
{
Type = "CascadeDeleteCharges",
Payload = new { }
}
}
}
};
}
public List<AutomationAction> EvaluateRules(
Type entityType,
EntityState state,
PropertyValues originalValues,
PropertyValues currentValues)
{
var applicableRules = _rules
.Where(r => r.EntityType == entityType &&
r.Operation == state.ToString())
.ToList();
var actions = new List<AutomationAction>();
foreach (var rule in applicableRules)
{
var json = JsonSerializer.Serialize(currentValues.ToDictionary());
if (rule.Trigger.Evaluate(json))
{
actions.AddRange(rule.Actions);
}
}
return actions;
}
}
|
Real Scenario: Transport Order Status Change
A single line in the service:
1
2
3
4
5
6
| public async Task UpdateOrderStatusAsync(int orderId, string newStatus)
{
var order = await _context.Orders.FindAsync(orderId);
order.Status = newStatus;
await _context.SaveChangesAsync(); // Interceptor fires here
}
|
The interceptor:
- Detects Status changed to “Completed”
- Evaluates automation rules → triggers “CalculatePricing” action
- Creates audit snapshot (Status: “Pending” → “Completed”)
- Queues notification message to Service Bus (fire-and-forget)
- Saves audit log, pending automation messages
- Commits transaction atomically
- Sends queued automation messages
Result:
- Audit trail created (compliance requirement met)
- Pricing calculated asynchronously (doesn’t block SaveChanges)
- Notification sent (customer informed)
- No gaps, no accidental skips
Disabling Audit for Batch Operations
During high-volume batch generation, audit logging adds overhead. Disable selectively:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class DisableableAuditInterceptor : SaveChangesInterceptor
{
private readonly ITenantService _tenantService;
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
if (_tenantService.IsAuditDisabled)
{
return result; // Skip audit processing entirely
}
// Normal audit logic
return result;
}
}
|
In batch service:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public async Task GenerateInvoicesAsync(int[] orderIds, CancellationToken cancellationToken)
{
_tenantService.DisableAudit();
try
{
// Batch operations without per-item audit overhead
// ...
}
finally
{
_tenantService.EnableAudit();
}
}
|
Lessons Learned: The Hard Way
1. Interceptors run on every SaveChanges
We had audit logic in interceptors that called SaveChangesAsync() internally, causing infinite recursion. Solution: queue audit entries to be saved in a nested transaction within the same SaveChanges call, not separately.
2. Snapshots must exclude navigation properties
Including navigation properties in snapshots bloated audit logs with duplicated entities. Use .Where(p => !p.Metadata.IsNavigationProperty) to capture primitive properties only.
3. Rule evaluation performance matters
JPath evaluation on every SaveChanges can add 50–100ms latency. Cache compiled rules, evaluate only on relevant entity types, and short-circuit on unchanged properties.
4. Audit failures should not fail business operations
If audit insert fails, don’t let it roll back the order update. Use try-catch around audit queuing; log audit failures separately (dead-letter queue) but commit business changes.
5. Testing interceptors is hard
Interceptors run at DbContext level. Write integration tests with real DbContext instances, not mocks. Unit test rule engines separately.
Gotchas and Disclaimers
- EF Core 8+: Interceptor API is stable; earlier versions have different method signatures.
- Async safety: Interceptor methods must be thread-safe. Don’t use thread-local state.
- Change tracker complexity: Complex object graphs with many relationships can slow down snapshot creation. Measure with large aggregates.
- .NET 9+: Recommended for async interceptor improvements.
Next Steps
- Extract audit logic from repositories and services into a base interceptor.
- Build automation rule engine with JPath expressions.
- Test rule evaluation independently with unit tests.
- Measure interceptor overhead in batch operations; tune or disable as needed.
Ready for featured image.