Introduction
As applications evolve, the need to track historical data or version control often arises. In this article, we’ll refactor an existing entity to support revisions using Entity Framework Core, allowing us to maintain a history of changes without duplicating the data model or introducing unnecessary complexity.
We’ll walk through a step-by-step approach to transition a simple Document
entity into a revisioned model. This method uses a discriminator to track multiple versions of the same document, with all revisions stored in the same database table. This approach is both flexible and scalable, making it easy to apply to other entities in your system.
Prerequisites
This guide assumes a basic understanding of Entity Framework Core and uses the following NuGet packages:
-
Microsoft.EntityFrameworkCore
(8.0.8) -
Microsoft.EntityFrameworkCore.Relational
(8.0.8)
Step 1: Defining the Initial Document
Entity
Let’s begin with a simple Document
entity representing a file or resource stored in the system. This basic setup works fine for many use cases but falls short when historical tracking or version control is needed.
public class Document
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Content { get; set; }
}
This model currently tracks only the latest state of a document. To introduce revisioning, we’ll need to refactor this structure.
Step 2: Refactoring to Support Revisions
To enable revisioning, we will introduce a base class DocumentBase
that holds shared properties and split the Document
entity into two types: one representing the current state and another representing historical revisions.
Updated Entities
public abstract class DocumentBase
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Content { get; set; }
}
public class Document : DocumentBase
{
// Represents the latest version of the document.
}
public class DocumentRevision : DocumentBase
{
// Represents an older version (revision) of the document.
}
With this setup, both the current document and its revisions share the same table structure, but they are logically distinct entities.
Step 3: Configuring the Discriminator in Entity Framework Core
Now, we need to configure Entity Framework Core to manage these two types (Document
and DocumentRevision
) in the same database table. This is done using a discriminator column that tells Entity Framework Core which rows represent revisions.
public class Context : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DocumentBase>(builder =>
{
builder.ToTable("Documents");
builder.HasKey(x => x.Id);
builder.HasDiscriminator<bool>("IsRevision")
.HasValue<Document>(false)
.HasValue<DocumentRevision>(true);
});
}
}
Here, we’ve defined an internal IsRevision
column that will indicate whether a row is a Document
or a DocumentRevision
. This allows us to store both types in the same Documents
table.
Step 4: Establishing the Document-Revision Relationship
A Document
can have multiple revisions. We’ll establish a one-to-many relationship between the Document
and its DocumentRevision
s.
Entity Relationship
public class Document : DocumentBase
{
private List<DocumentRevision>? _revisions;
public List<DocumentRevision> Revisions
{
get => _revisions ??= new List<DocumentRevision>();
set => _revisions = value;
}
}
public class DocumentRevision : DocumentBase
{
public Guid DocumentId { get; set; }
public Document Document { get; set; }
}
Configuring the Relationship in DbContext
modelBuilder.Entity<Document>()
.HasMany(d => d.Revisions)
.WithOne(r => r.Document)
.OnDelete(DeleteBehavior.Restrict);
The DeleteBehavior.Restrict
enforces that revisions must be deleted before the document itself can be removed, ensuring no orphaned DocumentRevisions. Alternatively, you could use DeleteBehavior.NoAction
, which allows orphaned revisions (in which case, DocumentId must be nullable).
Step 5: Encapsulating Revision Creation
To ensure clean design, it’s important to encapsulate revision creation within the Document
class. This prevents revisions from being manually created elsewhere, reducing the risk of inconsistent data.
public class Document : DocumentBase
{
public void CreateRevision()
{
Revisions.Add(new DocumentRevision(this));
}
}
Additionally, by making the DocumentRevision
constructor internal, we enforce that only Document
can create revisions:
public class DocumentRevision : DocumentBase
{
internal DocumentRevision(Document document)
{
Document = document;
DocumentId = document.Id;
Name = document.Name;
Content = document.Content;
}
}
This encapsulation ensures that all revision logic is properly handled in one place.
Step 6: Adding Revision Numbers and Timestamps
To further enhance revision tracking, we’ll add Revision
numbers and TimeStamp
s to the DocumentBase
class. This will allow us to track the history of changes more effectively.
public abstract class DocumentBase
{
public DateTime TimeStamp { get; protected set; }
public int Revision { get; protected set; }
}
public class Document : DocumentBase
{
public void CreateRevision()
{
TimeStamp = DateTime.UtcNow;
Revision += 1;
Revisions.Add(new DocumentRevision(this));
}
}
Handling in DocumentRevision
Each DocumentRevision
should inherit the revision number and timestamp from the original document when it’s created.
public class DocumentRevision : DocumentBase
{
internal DocumentRevision(Document document)
{
Document = document;
DocumentId = document.Id;
Name = document.Name;
Content = document.Content;
TimeStamp = document.TimeStamp;
Revision = document.Revision;
}
}
Demonstrating the Revision Process
Let’s see how this works in practice together with a database.
NOTE: The TimeStamp field has been omitted from the tables below for clarity. You can track the revisions using the Revision number.
Create a new document:
var document = new Document
{
Name = "My Document",
Content = "Initial content"
};
context.Set<Document>().Add(document);
context.SaveChanges();
Id | Revision | IsRevision | Name | Content | DocumentId |
---|---|---|---|---|---|
3ef0de57... | 0 | false | My Document | Initial content | NULL |
Create a revision and update the document:
document.CreateRevision();
document.Content = "Updated content";
context.Set<Document>().Update(document);
context.SaveChanges();
Id | Revision | IsRevision | Name | Content | DocumentId |
---|---|---|---|---|---|
3ef0de57... | 1 | false | My Document | Updated content | NULL |
90f436e6... | 0 | true | My Document | Initial content | 3ef0de57... |
Create another revision and update the document’s name:
document.CreateRevision();
document.Name = "Updated Document";
context.Set<Document>().Update(document);
context.SaveChanges();
Id | Revision | IsRevision | Name | Content | DocumentId |
---|---|---|---|---|---|
3ef0de57... | 2 | false | Updated Document | Updated content | NULL |
90f436e6... | 0 | true | My Document | Initial content | 3ef0de57... |
1e0d0ce0... | 1 | true | My Document | Updated content | 3ef0de57... |
Conclusion
By introducing revision support using discriminators in Entity Framework Core, you can track entity changes over time while keeping your data model simple. This flexible approach allows you to store both current and historical versions in the same table, making it easy to manage document versions and maintain a full audit trail.
However, will this same-table solution scale? Not indefinitely. If lookups on the active version are more common than on revisions, it’s wise to consider separating revisions into a different table, especially with large datasets. Fortunately, transitioning to a separate table for revisions is straightforward with this design — simply remove the discriminator setup from the ModelBuilder
. Just remember to migrate your data when making the switch.
Last Recommendation
To avoid overwriting a Document
without creating a revision, consider enforcing encapsulation on mutation methods. This will ensure consistent and reliable data tracking across your application.
Top comments (2)
Hello, I was trying to adopt this for an MVC web app and ran into problems during the Edit HttpPost action because the document parameter already contains the updated field values.
If you call CreateRevision() on that instance, it does create the revision, but with the new field values, so the values from the current version are lost when the Update(document) occurs.
I had to retrieve the current version and call CreateRevision() on it, mostly ignoring the document parameter except to grab the new field values, otherwise the entire Edit fails because the two updates conflict. ERROR = The instance of entity type 'Document' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked.
First of, I would use a dto instead of leaking entities in my endpoints.
That said, I haven't tried your scenario, but I think attaching the entity to the context would work, instead of loading it.