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);
		});
	}
}
Entity Framework Core
Entity Framework Core
A lightweight, extensible, open-source, and cross-platform version of the Entity Framework data access technology.
779 questions
SQL Server
SQL Server
A family of Microsoft relational database management and analysis systems for e-commerce, line-of-business, and data warehousing solutions.
14,494 questions
{count} votes

1 answer

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

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.