DEV Community

Jimmy
Jimmy

Posted on

Adding Revision Support to Entities in Entity Framework Core

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; }
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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 DocumentRevisions.

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; }
}
Enter fullscreen mode Exit fullscreen mode

Configuring the Relationship in DbContext

modelBuilder.Entity<Document>()
            .HasMany(d => d.Revisions)
            .WithOne(r => r.Document)
            .OnDelete(DeleteBehavior.Restrict);
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 TimeStamps 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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode
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 (1)

Collapse
 
dfc336dcb1f profile image
Moses Bunting

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.

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Edit(Guid id, [Bind("Id,Name,Content,TimeStamp,Revision")] Document document)
Enter fullscreen mode Exit fullscreen mode

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.

                    document.CreateRevision();
                    _context.Update(document);
                    await _context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

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.

                    var originalDocument = _context.Document.Find(id);
                    originalDocument.CreateRevision();
                    originalDocument.Name = document.Name;
                    originalDocument.Content = document.Content;
                    _context.Update(originalDocument);
                    await _context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode