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 Core 8, but when I upgraded to EF Core 9, I get 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.
This error also occurs when editing a comment, whereas in the previous version (EF Core 8) this error did not occur.
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);
});
}
}
I found the problem. Since I have defined a single entity in the database Post and Comment. And on the other hand, the DDD rules say that it should be a reference to the id and not the object itself. In case I mistakenly considered Post and Comment as two different entities. For this reason, the entity code (entity in DDD) Comment was a reference to the id:
using ContentService.Core.Domain.Aggregates.Posts.ValueObjects;
using FluentResults;
using MDF.Framework.SeedWork;
using MDF.Resources.Common;
using MDF.Resources.Common.FormattedMessages;
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;
}
}
Notice the Guid postId
in the code above. But I should have referenced the object like this:
using ContentService.Core.Domain.Aggregates.Posts.ValueObjects;
using FluentResults;
using MDF.Framework.SeedWork;
using MDF.Resources.Common;
using MDF.Resources.Common.FormattedMessages;
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 Post Post { get; private set; }
private Comment()
{
}
private Comment(Post post, DisplayName name, Email email, CommentText text) : this()
{
Post = post;
Name = name;
Email = email;
CommentText = text;
}
public static Result<Comment> Create(Post? post, string? name, string? email, string? text)
{
Result<Comment> result = new();
if (post is null)
{
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(post!, displayNameResult.Value, emailResult.Value, textResult.Value);
result.WithValue(returnValue);
return result;
}
}
In the above code which works correctly, instead of referencing the id
in the Comment class
, there is a reference to the Post object
. Because Post and Comment are one entity and not different entities.