Two-phase audit: SQL for atomic durable writes, Elasticsearch for fast reads.
[Handler]
│
├─ QueueAudit() ──► EF context (same DbContext, no SaveChanges)
│
└─ SaveChangesAsync() ──► SQL (entity + audit in ONE transaction)
│
[Index Service Consumer]
│
└─► Elasticsearch (async, after commit)
Pattern: ITransmittalAuditQueue writes to same ApplicationDbContext — no extra roundtrip.
// All Update/Delete/Status handlers:
_auditQueue.QueueAudit(AuditEntityTypes.Transmittal, entity.Id,
AuditEventTypes.Claimed, workspaceId, userId, new { claimedById = userId });
await _repo.UnitOfWork.SaveChangesAsync(ct); // entity + audit in 1 tx ✅
// All Create handlers (ID unknown before save):
await using var tx = await _repo.UnitOfWork.Database.BeginTransactionAsync(ct);
await _repo.AddAsync(entity, ct);
await _repo.UnitOfWork.SaveChangesAsync(ct); // entity gets DB-assigned ID
_auditQueue.QueueAudit(..., entity.Id, AuditEventTypes.Created, ...);
await _repo.UnitOfWork.SaveChangesAsync(ct); // audit in same tx
await tx.CommitAsync(ct); // both committed ✅
Interface: ITransmittalAuditQueue.QueueAudit(entityType, entityId, eventType, workspaceId, actorUserId, payload)
Impl: TransmittalAuditQueue → _context.TransmittalAuditLogs.Add(log) only — no SaveChanges
Table: TransmittalAuditLog in ApplicationDbContext (same DB as transmittal entities)
Pattern: CoreAuditInterceptor (SaveChangesInterceptor, Singleton) — auto-captures all entities.
SavingChangesAsync → scan ChangeTracker → AsyncLocal<List<PendingAuditEntry>>
SavedChangesAsync → IDs now set → write CoreAuditLog via IAuditLogRepository
AuditLogDbContext (separate connection, may be separate DB)try-catch swallows audit failure — main operation never blockedEntities tracked:
| Entity | Events |
|---|---|
| User | Created, Updated, Deleted |
| Role | Created, Updated, Deleted |
| Group | Created, Updated, Deleted |
| Pool, State, Metadata, ContactAlias, Workspace, Dashboard, Widget, FileTemplate | Created, Updated, Deleted |
| UserRole junction | User:RoleAssigned + Role:UserAssigned / User:RoleRevoked + Role:UserRevoked |
| UserGroup junction | User:GroupAssigned + Group:UserAssigned / User:GroupRevoked + Group:UserRevoked |
| UserPool junction | User:PoolAssigned / User:PoolRevoked |
Payload rules:
{ oldValues: {...}, newValues: {...} } (changed props only){ roleId } / { groupId } / { poolId } (flat, no wrapping)After SaveChanges commits, an event triggers the Index Service to replicate audit logs to Elasticsearch.
SQL commit
│
└─► [MassTransit event: AuditLogWrittenEvent]
│
└─► Index Service Consumer
│
└─► ES index: pplus-audit-{workspaceId}
ES document shape:
{
"id": "guid",
"entityType": "Transmittal",
"entityId": 123,
"eventType": "Claimed",
"workspaceId": 1,
"actorUserId": 42,
"occurredAt": "2026-06-25T10:00:00Z",
"payload": { "claimedById": 42 }
}
Read API: GET /{entity}/{id}/logs?page=1&pageSize=20
AuditEntityTypes (both services)Transmittal, Correspondence, CorrespondenceType, Property, TemplateUser, Role, Group, Pool, State, Metadata, ContactAlias, Workspace, Dashboard, Widget, FileTemplate
AuditEventTypes| Category | Events |
|---|---|
| CRUD | Created, Updated, Deleted, StatusUpdated |
| Transmittal lifecycle | Sent, Superseded, Replied, Claimed, Released, Read, Undone |
| Bookmarks | BookmarkSet, BookmarkRemoved, BookmarksCleared |
| Attachments | AttachmentFlipped, AttachmentLocked, AttachmentUnlocked, AttachmentEmailed, AttachmentStampDateUpdated, StampSequenceGenerated |
| Approval flow | ApprovalCreated, Resubmitted, Forwarded, Returned, DraftForwarded, ReturnedFromDraftForward, ApproveReview, ApproveSubmit, ApprovedAndSent |
| User/Role/Group | RoleAssigned, RoleRevoked, GroupAssigned, GroupRevoked, PoolAssigned, PoolRevoked, UserAssigned, UserRevoked |
| Service | Approach | Create | Update/Delete | Audit failure |
|---|---|---|---|---|
| Transmittal | ITransmittalAuditQueue (same DbContext) |
✅ explicit tx | ✅ single tx | ❌ throws (intentional) |
| Core | CoreAuditInterceptor (separate AuditLogDbContext) |
⚠️ non-atomic | ⚠️ non-atomic | ✅ swallowed (warning log) |
QueueAudit — NEVER call SaveChanges inside — handler owns the transactionBeginTransactionAsync wrapperEventId (Guid) — unique per audit row; prevents duplicate audit on EF retryactorUserId — nullable; null when no HTTP context (background jobs, migrations)Microservices/Core/
PPlus.CoreService.Infrastructure/Interceptors/CoreAuditInterceptor.cs
PPlus.CoreService.Infrastructure/Extentions/ServiceCollectionExtensions.cs ← AddInterceptors
PPlus.CoreService.Application/Queues/AuditLog/AuditConst.cs ← AuditEntityTypes, AuditEventTypes
Microservices/Transmittal/
PPlus.TransmittalService.Application/Interfaces/ITransmittalAuditQueue.cs
PPlus.TransmittalService.Application/Queues/AuditLog/AuditConst.cs ← AuditEntityTypes, AuditEventTypes
PPlus.TransmittalService.Infrastructure/Services/TransmittalAuditQueue.cs
PPlus.TransmittalService.Infrastructure/DbContexts/ApplicationDbContext.cs ← TransmittalAuditLogs DbSet
PPlus.TransmittalService.Infrastructure/Extentions/ServiceCollectionExtensions.cs ← AddScoped<ITransmittalAuditQueue>