EF Core's Hidden Identity Crisis: The Owned Entity Cloning Problem
On this page5 sections ▾
You write the code, you write the tests, everything goes green. You deploy with confidence. Then production blows up with a cryptic error that makes absolutely no sense.
This is one of those errors. An InvalidOperationException from Entity Framework Core that seems to defy logic, especially when your tests insist everything is fine.
#The Scenario: Cloning an Entity
Let's imagine a simple data model for a blog. We have a BlogPost entity, and each post can have a collection of simple Tag objects. Crucially, the Tags don't have their own ID; they belong exclusively to a blog post and will be stored as a JSON column in the database.
Here are our entity classes:
// The parent entity
public class BlogPost
{
public Guid Id { get; set; }
public string Title { get; set; }
public int Version { get; set; }
// A collection of child objects with no independent identity
private readonly List<Tag> _tags = new();
public IReadOnlyList<Tag> Tags => _tags;
// A method to create a new version of the post
public BlogPost CreateNewVersion()
{
var newVersion = new BlogPost
{
Id = Guid.NewGuid(), // A new Id for the new version
Title = this.Title,
Version = this.Version + 1,
};
// This is the problematic line
newVersion._tags.AddRange(this.Tags);
return newVersion;
}
public void AddTag(string tagText)
{
_tags.Add(new Tag { Text = tagText });
}
}
// The child object, stored as JSON
public class Tag
{
public string Text { get; set; }
}To configure this in EF Core, we use OwnsMany to define the relationship and ToJson to specify the storage mechanism.
// In your DbContext's OnModelCreating or an IEntityTypeConfiguration<BlogPost>
public void Configure(EntityTypeBuilder<BlogPost> builder)
{
builder.HasKey(b => b.Id);
// This is the key line:
// It tells EF Core that Tags are owned by BlogPost and stored as JSON.
builder.OwnsMany(b => b.Tags, owned =>
{
owned.ToJson();
});
}The CreateNewVersion method seems simple enough. It creates a new BlogPost, gives it a new ID, and copies over the tags. What could go wrong?
#The Error and the "Why"
When you run this code in a live application, you might get this baffling error after trying to save the new version:
InvalidOperationException: The property 'Tag.BlogPostId' is part of a key and so cannot be modified or marked as modified.
Your first reaction: "What BlogPostId? My Tag class doesn't have that property!"
You're right, it doesn't. The error is referring to a conceptual, in-memory shadow property that EF Core creates because you used OwnsMany.
OwnsMany tells EF Core that Tag is an owned entity. It cannot exist without a BlogPost. To manage this, the EF Core Change Tracker creates an in-memory identity for each Tag instance that includes a link back to its parent (the conceptual BlogPostId).
Here's the chain of events in your live application:
- You load the original
BlogPostfrom the database. EF Core begins tracking theBlogPostand all its childTagobject instances. - You call
CreateNewVersion(). - The line
newVersion._tags.AddRange(this.Tags)takes the exact same C# object instances from the original post and adds them to the new version. - You try to save the new
BlogPost. The Change Tracker sees theTagobjects, recognizes them as the ones it's already tracking, and sees that you're trying to assign them to a new parent. - This requires changing their conceptual
BlogPostId, which is part of their identity. EF Core forbids changing a key property and throws the exception.
#But why do my tests pass?
The same code fails in production but passes in integration tests, even when both environments look identical on paper. That points to something subtle in the execution context.
#What I ruled out
I went through the obvious suspects and confirmed they matched in both test and live scenarios:
DbContextlifetime: correctly scoped to the individual operation in both cases- Initial data state: the entity being cloned was loaded from the database first in both environments
- Database provider: both used the same SQL Server provider
- Change tracking configuration: the default
QueryTrackingBehaviorwas identical
#The Solution: Create New Instances
The fix is to stop reusing the old object instances and instead create new ones for the clone.
Update the CreateNewVersion method to project the old tags into new Tag objects.
public BlogPost CreateNewVersion()
{
var newVersion = new BlogPost
{
Id = Guid.NewGuid(),
Title = this.Title,
Version = this.Version + 1,
};
// The Fix: Create NEW Tag instances instead of reusing the old ones
var newTags = this.Tags.Select(t => new Tag { Text = t.Text });
newVersion._tags.AddRange(newTags);
return newVersion;
}By using .Select(t => new Tag { ... }), you are creating brand new Tag objects. The Change Tracker has never seen these instances before, so it correctly treats them as new children of the new BlogPost, and everything saves perfectly.
With EF Core, the identity of your C# object instances is just as important as the data they hold.
#Conclusion
When all the obvious factors match and the bug still only shows up in production, the cause is usually something subtle in how the two environments run your code. That's worth keeping in mind as you troubleshoot.
But the bigger point here is that the original cloning logic was fragile. Whether it worked depended entirely on what the EF Core Change Tracker happened to know at the time. Creating new instances of the owned child entities cuts that dependency entirely. It works the same way every time, regardless of what's been tracked and what hasn't. That's the version you want in production.