My ASP.NET Core Web API app uses SQL Server and I have the following service registered in Microsoft.DependencyInjection as a scoped service:
public class UpdateOperation<TEntity, TDto>(
IMapper mapper,
ApplicationDbContext dbContext,
ICurrentUtcTimeProvider currentUtcTimeProvider)
: IUpdateOperation<TEntity, TDto>
where TEntity : UserEntityBase
where TDto : DtoBase
{
private readonly IMapper _mapper = mapper;
private readonly ApplicationDbContext _dbContext = dbContext;
private readonly ICurrentUtcTimeProvider _currentUtcTimeProvider = currentUtcTimeProvider;
/// <inheritdoc />
public async Task UpdateAsync(TEntity? existingEntity, TDto newDto, string userId)
{
if (existingEntity is null)
return;
_mapper.Map(newDto, existingEntity);
if (existingEntity is IUpdateHistory entityUpdateHistory)
{
entityUpdateHistory.UpdatedAt = _currentUtcTimeProvider.GetCurrentUtcTime();
entityUpdateHistory.UpdatedBy = userId;
}
_dbContext.Update(existingEntity);
await _dbContext.SaveChangesAsync();
}
}
And an older service which simply changes some properties of an IUpdateHistory
entity:
public class UpdateInfoOperation(
ICurrentUtcTimeProvider currentUtcTimeProvider)
: IUpdateInfoOperation
{
private readonly ICurrentUtcTimeProvider _currentUtcTimeProvider = currentUtcTimeProvider;
/// <inheritdoc />
public void UpdateInfo(IUpdateHistory entity, string userId)
{
ArgumentNullException.ThrowIfNull(userId);
entity.UpdatedBy = userId;
entity.UpdatedAt = _currentUtcTimeProvider.GetCurrentUtcTime();
}
}
Then, I have repository services that use one of the services. If they use UpdateInfoOperation
and call the DbContext.Update() method by themselves, everything works fine:
/// <inheritdoc />
public async Task UpdateAsync(Guid id, RailLineDto newDto, string userId)
{
RailLine? existingEntity = await FindEntityByIdAsync(id, userId);
if (existingEntity == null)
return;
_mapper.Map(newDto, existingEntity);
_updateInfoOperation.UpdateInfo(existingEntity, userId);
_dbContext.Update(existingEntity);
await _dbContext.SaveChangesAsync();
}
/// <summary>
/// Asynchronously finds a RailLine entity by its identifier and user ID.
/// </summary>
/// <param name="id">The ID of the RailLine entity to find.</param>
/// <param name="userId">The ID of the user who owns the entity.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the RailLine entity if found; otherwise, null.</returns>
private async Task<RailLine?> FindEntityByIdAsync(Guid id, string userId)
{
return await
_dbContext.RailLines
.Include(l => l.Stations)
.Include(l => l.SpeedSections)
.Include(l => l.Gradients)
.Include(l => l.Curves)
.Include(l => l.Tunnels)
.Include(l => l.ElectrifiedSections)
.Include(l => l.Substations)
.FirstOrDefaultAsync(l => l.Id == id && l.UserId == userId && !l.IsDeleted);
}
But when they use UpdateOperation
and the method above simply calls UpdateOperation.UpdateAsync(...), which calls FindEntityByIdAsync(...) as its first parameter, I end up with both new and old child entities in the DB. For example, if a list contained 2 items and the updated list also contains 2 items (regardless if changed or unchanged), there are now 4 items in the DB and the GET request also returns 4 items. What should I change to make it work with UpdateOperation
?
I tried manually switching the EntityState to Modified, but it didn't work. To me, the code seems the same and I only moved it elsewhere, so there should be no problem. Or am I missing something?
Duplicating behaviour will occur when related entities have their PK's wiped, or marked as Added
rather than Modified
. I suspect that the DbContext
instance for this service is resolving as a different instance to what the repository is using. Update()
will normally start tracking related entities in a Modified
state automatically, and provided the DbContext
isn't already tracking an instance of that entity, it should attach to the separate DbContext
instance and work properly provided the PKs for the related entities haven't been wiped.
A possible red flag is the Mapper .Map()
call. How is your mapper configured to handle the child references/collections? I would inspect the entities after the .Map(src, dest)
call, specifically the child collections, to see that their PKs have not been cleared/defaulted, and more importantly that the mapper is not doing something re-adding items to the collections. If for instance the repository and service are using the same DbContext
instance, .Update()
is completely unnecessary, and if the Map call in the service is instantiating a new instance of the child object, even if the PK is set, this would be marked as Added
with the DbContext
change tracking. Since Automapper is used in both scenarios, is this using the exact same mapper configuration?