Skip to content

Commit d4dd472

Browse files
committed
refactor(infrastructure): extract timestamp update logic and implement lead repository
- Extract UpdateModifiedLeadTimestamps() method to eliminate DRY violation * Caches dateTimeProvider.UtcNow once to ensure transactional consistency * All modified entities in same transaction receive identical timestamp * Improves performance with early exit when no entities modified * Single source of truth for timestamp update logic - Implement LeadRepository with EF Core * SaveLeadAsync: Handles insert/update with automatic timestamp management * ExistsByCorrelationIdAsync: Optimized query for idempotency checks * GetByCorrelationIdAsync: Efficient correlation ID lookups * Automatic EF Core tracking conflict resolution * Full cancellation token support * Comprehensive input validation and exception handling - All 24 repository tests passing - All 9 DbContext tests passing (no regressions) - 175 total tests passing (100% pass rate) - Zero build warnings/errors This completes Step 4.3 and prepares infrastructure for Step 4.4 configuration settings.
1 parent 8881542 commit d4dd472

File tree

3 files changed

+637
-15
lines changed

3 files changed

+637
-15
lines changed

src/LeadProcessor.Infrastructure/Persistence/LeadProcessorDbContext.cs

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -120,18 +120,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
120120
/// </remarks>
121121
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
122122
{
123-
var modifiedLeadEntries = ChangeTracker.Entries<Lead>()
124-
.Where(e => e.State == EntityState.Modified)
125-
.ToList();
126-
127-
foreach (var entry in modifiedLeadEntries)
128-
{
129-
// Create a new Lead instance with the updated timestamp
130-
// This is required because Lead is a record type (immutable)
131-
var updatedLead = entry.Entity with { UpdatedAt = dateTimeProvider.UtcNow };
132-
entry.CurrentValues.SetValues(updatedLead);
133-
}
134-
123+
UpdateModifiedLeadTimestamps();
135124
return await base.SaveChangesAsync(cancellationToken);
136125
}
137126

@@ -144,18 +133,41 @@ public override async Task<int> SaveChangesAsync(CancellationToken cancellationT
144133
/// This synchronous method is provided for compatibility but async methods should be preferred.
145134
/// </remarks>
146135
public override int SaveChanges(bool acceptAllChangesOnSuccess)
136+
{
137+
UpdateModifiedLeadTimestamps();
138+
return base.SaveChanges(acceptAllChangesOnSuccess);
139+
}
140+
141+
/// <summary>
142+
/// Updates the UpdatedAt timestamp for all modified Lead entities in the change tracker.
143+
/// </summary>
144+
/// <remarks>
145+
/// This method caches the current UTC time once to ensure all modified entities
146+
/// in the same transaction receive the exact same timestamp, maintaining transactional consistency.
147+
/// The timestamp update is required to maintain immutability of record types while ensuring audit trail accuracy.
148+
/// </remarks>
149+
private void UpdateModifiedLeadTimestamps()
147150
{
148151
var modifiedLeadEntries = ChangeTracker.Entries<Lead>()
149152
.Where(e => e.State == EntityState.Modified)
150153
.ToList();
151154

155+
if (modifiedLeadEntries.Count == 0)
156+
{
157+
return;
158+
}
159+
160+
// Cache the timestamp once to ensure all entities get the same value
161+
// This maintains transactional consistency
162+
var updateTimestamp = dateTimeProvider.UtcNow;
163+
152164
foreach (var entry in modifiedLeadEntries)
153165
{
154-
var updatedLead = entry.Entity with { UpdatedAt = dateTimeProvider.UtcNow };
166+
// Create a new Lead instance with the updated timestamp
167+
// This is required because Lead is a record type (immutable)
168+
var updatedLead = entry.Entity with { UpdatedAt = updateTimestamp };
155169
entry.CurrentValues.SetValues(updatedLead);
156170
}
157-
158-
return base.SaveChanges(acceptAllChangesOnSuccess);
159171
}
160172
}
161173

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using LeadProcessor.Domain.Entities;
2+
using LeadProcessor.Domain.Repositories;
3+
using LeadProcessor.Infrastructure.Persistence;
4+
using Microsoft.EntityFrameworkCore;
5+
6+
namespace LeadProcessor.Infrastructure.Repositories;
7+
8+
/// <summary>
9+
/// EF Core implementation of <see cref="ILeadRepository"/> for managing lead persistence operations.
10+
/// </summary>
11+
/// <remarks>
12+
/// This repository provides data access operations for the Lead entity using Entity Framework Core.
13+
/// All operations are async and support cancellation tokens for graceful shutdown.
14+
/// Thread-safe when used with proper DbContext lifecycle management (scoped per request).
15+
/// </remarks>
16+
/// <param name="context">The database context for lead operations.</param>
17+
public sealed class LeadRepository(LeadProcessorDbContext context) : ILeadRepository
18+
{
19+
private readonly LeadProcessorDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
20+
21+
/// <summary>
22+
/// Saves a lead to the data store asynchronously.
23+
/// </summary>
24+
/// <param name="lead">The lead entity to save.</param>
25+
/// <param name="cancellationToken">Cancellation token to cancel the operation.</param>
26+
/// <returns>The saved lead entity with updated fields (e.g., Id, timestamps).</returns>
27+
/// <exception cref="ArgumentNullException">Thrown when lead is null.</exception>
28+
/// <exception cref="DbUpdateException">Thrown when a database update error occurs.</exception>
29+
/// <remarks>
30+
/// This method handles both insert and update operations:
31+
/// - For new leads (Id == 0), performs an INSERT
32+
/// - For existing leads (Id > 0), performs an UPDATE
33+
/// The UpdatedAt timestamp is automatically updated by the DbContext on save.
34+
/// For updates, detaches any tracked entity to prevent tracking conflicts.
35+
/// </remarks>
36+
public async Task<Lead> SaveLeadAsync(Lead lead, CancellationToken cancellationToken = default)
37+
{
38+
ArgumentNullException.ThrowIfNull(lead);
39+
40+
// Determine if this is a new entity or an existing one
41+
if (lead.Id == 0)
42+
{
43+
// New entity - add to context for insert
44+
await _context.Leads.AddAsync(lead, cancellationToken);
45+
}
46+
else
47+
{
48+
// Existing entity - check if it's already tracked
49+
var trackedEntity = _context.Leads.Local.FirstOrDefault(l => l.Id == lead.Id);
50+
if (trackedEntity != null)
51+
{
52+
// Detach the tracked entity to avoid conflicts
53+
_context.Entry(trackedEntity).State = EntityState.Detached;
54+
}
55+
56+
// Attach and mark as modified for update
57+
_context.Leads.Update(lead);
58+
}
59+
60+
await _context.SaveChangesAsync(cancellationToken);
61+
return lead;
62+
}
63+
64+
/// <summary>
65+
/// Checks if a lead with the specified correlation ID already exists.
66+
/// This method supports idempotency by allowing duplicate message detection.
67+
/// </summary>
68+
/// <param name="correlationId">The correlation ID to check for.</param>
69+
/// <param name="cancellationToken">Cancellation token to cancel the operation.</param>
70+
/// <returns>True if a lead with the correlation ID exists, otherwise false.</returns>
71+
/// <exception cref="ArgumentException">Thrown when correlationId is null or whitespace.</exception>
72+
/// <remarks>
73+
/// This method uses an optimized query that only checks for existence without loading the entity.
74+
/// It leverages the indexed CorrelationId column for fast lookups.
75+
/// </remarks>
76+
public async Task<bool> ExistsByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default)
77+
{
78+
if (string.IsNullOrWhiteSpace(correlationId))
79+
{
80+
throw new ArgumentException("Correlation ID cannot be null or whitespace.", nameof(correlationId));
81+
}
82+
83+
return await _context.Leads
84+
.AnyAsync(l => l.CorrelationId == correlationId, cancellationToken);
85+
}
86+
87+
/// <summary>
88+
/// Retrieves a lead by its correlation ID asynchronously.
89+
/// </summary>
90+
/// <param name="correlationId">The correlation ID to search for.</param>
91+
/// <param name="cancellationToken">Cancellation token to cancel the operation.</param>
92+
/// <returns>The lead entity if found, otherwise null.</returns>
93+
/// <exception cref="ArgumentException">Thrown when correlationId is null or whitespace.</exception>
94+
/// <remarks>
95+
/// This method leverages the indexed CorrelationId column for efficient lookups.
96+
/// Returns null if no lead is found with the specified correlation ID.
97+
/// </remarks>
98+
public async Task<Lead?> GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default)
99+
{
100+
if (string.IsNullOrWhiteSpace(correlationId))
101+
{
102+
throw new ArgumentException("Correlation ID cannot be null or whitespace.", nameof(correlationId));
103+
}
104+
105+
return await _context.Leads
106+
.FirstOrDefaultAsync(l => l.CorrelationId == correlationId, cancellationToken);
107+
}
108+
}
109+

0 commit comments

Comments
 (0)