Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded.

Arman Espiar 1 Reputation point
2024-11-24T08:29:09.3733333+00:00

I am using DDD architecture and the following code changes the aggregate Root (basically adds a comment which is an Entity to the aggregate Root).

public sealed class AddCommentToPostCommandHandlers : ICommandHandler<AddCommentToPostCommand, Guid>
{
	private readonly IPostCommandRepository _postRepository;
	private readonly ILogger<AddCommentToPostCommandHandlers> _logger;
	public AddCommentToPostCommandHandlers(IPostCommandRepository postRepository, ILogger<AddCommentToPostCommandHandlers> logger)
	{
		_postRepository = postRepository;
		_logger = logger;
	}
	public async Task<Result<Guid>> Handle(AddCommentToPostCommand request, CancellationToken cancellationToken)
	{
		var post = await _postRepository.GetGraphByAsync(request.PostId, cancellationToken);
		if (post is not null)
		{
			post.AddComment(request.DisplayName, request.Email, request.CommentText);
			if (post.Result.IsSuccess)
			{
				_postRepository.UpdateBy(post);
				await _postRepository.CommitAsync(cancellationToken);
				return post.Id;
			}
			return post.Result;
		}
		return Result.Fail(ErrorMessages.NotFound(request.ToString()));
	}
}

This code worked fine with EF 8, but when I upgraded to EF 9, it gave the following error:

Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded.

Aggregate root code:

namespace ContentService.Core.Domain.Aggregates.Posts;
public class Post : AggregateRoot<Post>
{
	public Title Title { get; private set; }
	public Description Description { get; private set; }
	public Text Text { get; private set; }
	private readonly List<GuidId> _categoryIds;
	public virtual IReadOnlyList<GuidId> CategoryIds => _categoryIds;
	#region بارگذاری تنبل در سطح دامنه
	private List<Comment> _comments;
	public virtual IReadOnlyList<Comment> Comments
	{
		get
		{
			if (_comments == null)
			{
				LoadComments();
			}
			return _comments.AsReadOnly();
		}
	}
	private void LoadComments()
	{
		// Load comments from the data source here.
		// This is just a placeholder. You will need to replace this with your actual data loading logic.
		_comments = new List<Comment>();
	}
	#endregion End بارگذاری تنبل در سطح دامنه
	public Post()
	{
	
		_categoryIds = new List<GuidId>();
	}
	private Post(string? title, string? description, string? text) : this()
	{
		var titleResult = Title.Create(title);
		Result.WithErrors(titleResult.Errors);
		var descriptionResult = Description.Create(description);
		Result.WithErrors(descriptionResult.Errors);
		var contentResult = Text.Create(text);
		Result.WithErrors(contentResult.Errors);
		if (Result.IsSuccess)
		{
			Title = titleResult.Value;
			Description = descriptionResult.Value;
			Text = contentResult.Value;
		}
	}
	public Post Create(string? title, string? description, string? text)
	{
		var checkValidations = new Post(title, description, text);
		Result.WithErrors(checkValidations.Result.Errors);
		if (Result.IsFailed) return this;
		if (Result.IsSuccess)
		{
			this.Text = checkValidations.Text;
			this.Title = checkValidations.Title;
			this.Description = checkValidations.Description;
			RaiseDomainEvent(new PostCreatedEvent(Id, this.Title.Value, this.Description.Value, this.Text.Value));
		}
		return this;
	}
	public Post UpdatePost(string? title, string? description, string? text)
	{
		var checkValidations = new Post(title, description, text);
		Result.WithErrors(checkValidations.Result.Errors);
		if (Result.IsFailed) return this;
		if (Result.IsSuccess)
		{
			this.Title = checkValidations.Title;
			this.Description = checkValidations.Description;
			this.Text = checkValidations.Text;
			RaiseDomainEvent(new PostUpdatedEvent(Id, Title.Value!, Description.Value!, Text.Value!));
			Result.WithSuccess(SuccessMessages.SuccessUpdate(DataDictionary.Post));
		}
		return this;
	}
	public Post RemovePost(Guid? id)
	{
		var guidResult = GuidId.Create(id);
		if (guidResult.IsFailed)
		{
			Result.WithErrors(guidResult.Errors);
			return this;
		}
		//Note: if have IsDeleted property (soft delete) we can change to true here
		RaiseDomainEvent(new PostRemovedEvent(id));
		Result.WithSuccess(SuccessMessages.SuccessDelete(DataDictionary.Post));
		return this;
	}
	#region Category
	public Post AddCategory(Guid? categoryId)
	{
		var guidResult = GuidId.Create(categoryId);
		if (guidResult.IsFailed)
		{
			Result.WithErrors(guidResult.Errors);
			return this;
		}
		if (!_categoryIds.Contains(guidResult.Value))       //جلوگیری از تکراری بودن دسته بندی	
		{
			_categoryIds.Add(guidResult.Value);
			RaiseDomainEvent(new PostCategoryAddedEvent(Id, (Guid)categoryId!));
		}
		return this;
	}
	public Post ChangeCategory(Guid? oldCategoryId, Guid? newCategoryId)
	{
		var oldGuidResult = GuidId.Create(oldCategoryId);
		var newGuidResult = GuidId.Create(newCategoryId);
		if (oldGuidResult.IsFailed)
		{
			Result.WithErrors(oldGuidResult.Errors);
			return this;
		}
		if (newGuidResult.IsFailed)
		{
			Result.WithErrors(newGuidResult.Errors);
			return this;
		}
		if (_categoryIds.Contains(oldGuidResult.Value))
		{
			var indexOldCategory = _categoryIds.IndexOf(oldGuidResult.Value);
			if (!_categoryIds.Contains(newGuidResult.Value))
			{
				_categoryIds.RemoveAt(indexOldCategory);
				_categoryIds.Insert(indexOldCategory, newGuidResult.Value);
			}
			else
			{
				_categoryIds.RemoveAt(indexOldCategory);
			}
			RaiseDomainEvent(new CategoryPostChangedEvent(Id, (Guid)oldCategoryId!, (Guid)newCategoryId!));
		}
		else
		{
			Result.WithError(ErrorMessages.NotFound(DataDictionary.Category));
		}
		return this;
	}
	public Post RemoveCategory(Guid? categoryId)
	{
		var guidResult = GuidId.Create(categoryId);
		if (guidResult.IsFailed)
		{
			Result.WithErrors(guidResult.Errors);
			return this;
		}
		if (_categoryIds.Contains(guidResult.Value))
		{
			_categoryIds.Remove(guidResult.Value);
			RaiseDomainEvent(new CategoryPostRemovedEvent(Id, (Guid)categoryId!));
		}
		return this;
	}
	#endregion End Category
	#region Comments
	public Post AddComment(string? name, string? email, string? text)
	{
		var commentResult = Comment.Create(this, name, email, text);
		Result.WithErrors(commentResult.Errors);
		if (Result.IsFailed)
		{
			return this;
		}
		var hasAny = Comments
			.Any(c => c.Name == commentResult.Value.Name
					  && c.Email == commentResult.Value.Email
					  && c.CommentText == commentResult.Value.CommentText);
		if (hasAny)
		{
			var errorMessage = ValidationMessages.Repetitive(DataDictionary.Comment);
			Result.WithError(errorMessage);
			return this;
		}
		_comments.Add(commentResult.Value);
		RaiseDomainEvent(new CommentAddedEvent(this.Id, commentResult.Value.Id, commentResult.Value.Name.Value, commentResult.Value.Email.Value, commentResult.Value.CommentText.Value));
		return this;
	}
	public Post ChangeCommentText(string? name, string? email, string? text, string? newText)
	{
		var commentOldResult = Comment.Create(this, name, email, text);
		var commentNewResult = Comment.Create(this, name, email, newText);
		Result.WithErrors(commentOldResult.Errors);
		Result.WithErrors(commentNewResult.Errors);
		var emailGuardResult = Guard.CheckIf(commentNewResult.Value.Email, DataDictionary.Email)
			.Equal(commentOldResult.Value.Email);
		Result.WithErrors(emailGuardResult.Errors);
		var nameGuardResult = Guard.CheckIf(commentNewResult.Value.Name, DataDictionary.Name)
			.Equal(commentOldResult.Value.Name);
		Result.WithErrors(nameGuardResult.Errors);
		var commentTextGuardResult = Guard.CheckIf(commentNewResult.Value.CommentText, DataDictionary.CommentText)
			.NotEqual(commentOldResult.Value.CommentText);
		Result.WithErrors(commentTextGuardResult.Errors);
		if (Result.IsFailed)
		{
			return this;
		}
		LoadComments();
		var hasAny = Comments
			.Any(c => c.Name == commentNewResult.Value.Name
					  && c.Email == commentNewResult.Value.Email
					  && c.CommentText == commentNewResult.Value.CommentText);
		if (hasAny)
		{
			var errorMessage = ValidationMessages.Repetitive(DataDictionary.Comment);
			Result.WithError(errorMessage);
			return this;
		}
		//var commentIndex = _comments
		//	.FindIndex(c => c.Name == commentOldResult.Value.Name
		//			  && c.Email == commentOldResult.Value.Email
		//			  && c.CommentText == commentOldResult.Value.CommentText);
		var commentIndex = Comments
			.Select((c, i) => new { Comment = c, Index = i })
			.FirstOrDefault(x => x.Comment.Name == commentOldResult.Value.Name
								 && x.Comment.Email == commentOldResult.Value.Email
								 && x.Comment.CommentText == commentOldResult.Value.CommentText)?.Index;
		if (commentIndex >= 0)
		{
			_comments.RemoveAt((int)commentIndex);
			_comments.Insert((int)commentIndex, commentNewResult.Value);
			RaiseDomainEvent(new CommentEditedEvent(this.Id, commentNewResult.Value.Id, commentNewResult.Value.Name.Value, commentNewResult.Value.Email.Value, commentNewResult.Value.CommentText.Value));
		}
		return this;
	}
	public Post RemoveComment(string? name, string? email, string? text)
	{
		var commentResult = Comment.Create(this, name, email, text);
		Result.WithErrors(commentResult.Errors);
		if (Result.IsFailed)
		{
			return this;
		}
		var commentFounded = Comments
			.FirstOrDefault(c => c.Name?.Value?.ToLower() == commentResult.Value.Name?.Value?.ToLower()
								 && c.Email?.Value?.ToLower() == commentResult.Value?.Email?.Value?.ToLower()
								 && c.CommentText.Value?.ToLower() == commentResult?.Value?.CommentText.Value?.ToLower());
		if (commentFounded is null)
		{
			var errorMessage = ErrorMessages.NotFound(DataDictionary.Comment);
			Result.WithError(errorMessage);
			return this;
		}
		_comments.Remove(commentFounded);
		Result.WithSuccess(SuccessMessages.SuccessDelete(DataDictionary.Comment));
		RaiseDomainEvent(new CommentRemovedEvent(Id, name, email, text));
		return this;
	}
	#endregion
}


and comment entity is:

namespace ContentService.Core.Domain.Aggregates.Posts.Entities;
public class Comment : Entity
{
	public DisplayName Name { get; private set; }
	public Email Email { get; private set; }
	public CommentText CommentText { get; private set; }
	public Guid PostId { get; private set; }
	private Comment()
	{
	}
	private Comment(Guid postId, DisplayName name, Email email, CommentText text) : this()
	{
		PostId = postId;
		Name = name;
		Email = email;
		CommentText = text;
	}
	public static Result<Comment> Create(Guid? postId, string? name, string? email, string? text)
	{
		Result<Comment> result = new();
		if (!postId.HasValue || postId == Guid.Empty)
		{
			var errorMessage = ValidationMessages.Required(DataDictionary.Post);
			result.WithError(errorMessage);
		}
		var displayNameResult = DisplayName.Create(name);
		result.WithErrors(displayNameResult.Errors);
		var emailResult = Email.Create(email);
		result.WithErrors(emailResult.Errors);
		var textResult = CommentText.Create(text);
		result.WithErrors(textResult.Errors);
		if (result.IsFailed)
		{
			return result;
		}
		var returnValue = new Comment((Guid)postId!, displayNameResult.Value, emailResult.Value, textResult.Value);
		result.WithValue(returnValue);
		return result;
	}
}

and ef config:

internal sealed class PostConfiguration : IEntityTypeConfiguration<Post>
{
	public void Configure(EntityTypeBuilder<Post> builder)
	{
		builder.Property(p => p.CategoryIds)
			.HasConversion(
				v => string.Join(',', v.Select(c => c.Value)),
				v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(c => GuidId.Create(c).Value).ToList()
			);
		builder.Property(p => p.Title)
			.IsRequired(true)
			.HasMaxLength(Title.Maximum)
			.HasConversion(p => p.Value, p => Title.Create(p).Value);
		builder.Property(p => p.Description)
			.IsRequired(true)
			.HasMaxLength(Description.Maximum)
			.HasConversion(d => d.Value, d => Description.Create(d).Value);
		builder.Property(p => p.Text)
			.IsRequired(true)
			.HasConversion(t => t.Value, t => Text.Create(t).Value);
		builder.OwnsMany<Comment>(c => c.Comments, cc =>
		{
			cc.ToTable("Comments");
			cc.Property(c => c.Email)
				.IsRequired(true)
				.HasConversion(e => e.Value, e => Email.Create(e).Value);
			cc.Property(c => c.Name)
				.IsRequired(true)
				.HasMaxLength(DisplayName.Maximum)
				.HasConversion(e => e.Value, e => DisplayName.Create(e).Value);
			cc.Property(c => c.CommentText)
				.IsRequired(true)
				.HasMaxLength(CommentText.Maximum)
				.HasConversion(e => e.Value, e => CommentText.Create(e).Value);
		});
	}
}
Developer technologies | .NET | Entity Framework Core
SQL Server | Other
{count} votes

2 answers

Sort by: Most helpful
  1. Robert Eru 0 Reputation points
    2025-05-10T16:53:35.6833333+00:00

    Try this

    
    
    // Configure the Tiers collection as owned entities
    builder.OwnsMany(fc => fc.Tiers, tierBuilder =>
    {
        tierBuilder.ToTable("FeeTierDefinitions", Schemas.Default);
        
        tierBuilder.WithOwner().HasForeignKey("FeeConfigurationId");
        
        tierBuilder.HasKey("Id");
        tierBuilder.Property<Guid>("Id")
       .ValueGeneratedNever();
    
    

    .ValueGeneratedNever() tells EF Core that the application (not the database) is responsible for setting the ID.

    • EF Core will now always treat new objects with a non-default GUID as new inserts, not updates.
    • This resolves the concurrency exception when adding new items to the owned collection.
      • .ValueGeneratedNever() tells EF Core that the application (not the database) is responsible for setting the ID.
      • EF Core will now always treat new objects with a non-default GUID as new inserts, not updates.
      • This resolves the concurrency exception when adding new items to the owned collection.
    0 comments No comments

  2. Đào Quân 0 Reputation points
    2025-08-07T12:30:58.5333333+00:00

    I had the same problem, the error is when you get post:

    var post = await _postRepository.GetGraphByAsync(request.PostId, cancellationToken); 
    

    then EF 9 is understanding this "post" to be taken out and modified and it marks the entry as Modified => this is correct.
    But when you add a new comment:

    post.AddComment(request.DisplayName, request.Email, request.CommentText);   
    

    then if correct EF should mark it as an Added entry but it marks it as Entry Modified => this is wrong because the new Comment does not exist yet so it generates an error.


Your answer

Answers can be marked as Accepted Answers by the question author, which helps users to know the answer solved the author's problem.