A Data Versioning System keeps the complete history of changes to your records: who changed what, when, and how to recover or audit older versions. This is critical for audit, debugging, compliance (GDPR/financial), and features such as “time travel”, record revert, and change comparisons.

This article gives a practical, production-ready design with multiple implementation options (built-in temporal tables, manual history tables with triggers, and application-level capture), ER diagrams, flowcharts, SQL scripts, ASP.NET Core patterns, and best practices.

Goals

High-level architecture (Visio-style)

+-----------------+     writes/reads     +-------------------+
|  Application    | <------------------> |  Primary DB       |
|  (ASP.NET Core) |                      |  (SQL Server)     |
+-----------------+                      +-------------------+
        |                                        |
        |                                        v
        |                              +-----------------------+
        |                              |  Versioning Store     |
        |                              | - Temporal Tables OR  |
        |                              | - History Tables +    |
        |                              |   Triggers            |
        |                              +-----------------------+
        |                                        |
        v                                        v
+-----------------+                      +-------------------+
| Audit / Admin   |                      | Archive / Cold    |
| UI (history /   |                      | storage (S3/Blob) |
| compare / revert)|                     +-------------------+
+-----------------+

Flowchart (change -> capture -> query -> revert)

User updates record
       ↓
Application executes SQL
       ↓
Versioning mechanism captures change
 (temporal OR trigger OR app-level)
       ↓
History stored with metadata (user, ts, txid)
       ↓
Admin queries history / diffs
       ↓
Admin reverts (optional) -> write new record (audit captured)

ER diagram (core objects)

+-------------------+        +------------------------+
|   MyEntity        | 1     1|   MyEntity_History     |
+-------------------+ <------>+-----------------------+
| Id (PK)           |        | HistoryId (PK)        |
| Name              |        | EntityId (FK)         |
| Value             |        | ValidFrom             |
| ...               |        | ValidTo               |
+-------------------+        | ChangedBy             |
                             | ChangeType (I/U/D)    |
                             | PayloadJson (full row)|
                             | ChangeReason          |
                             | TxId                  |
                             +-----------------------+

Two main approaches (summary)

  1. System-versioned temporal tables (SQL Server feature, simple, performant, automatic).

    • Pros: automatic row-versioning, built-in time travel query, efficient.

    • Cons: less flexible metadata (who/why), harder to store JSON diffs, needs SQL Server 2016+.

  2. Manual history tables + triggers / stored procedures.

    • Pros: full control (store user, reason, JSON diffs, tx ids), easier to extend.

    • Cons: more code, need to manage triggers and retention.

Additionally, application-level capture (EF Core interceptors, change tracker) can supplement to include user/context info and business logic.

Option A — System-versioned temporal table (recommended when available)

1) Create temporal table

CREATE TABLE dbo.Customer
(
    CustomerId   UNIQUEIDENTIFIER NOT NULL PRIMARY KEY DEFAULT NEWID(),
    Name         NVARCHAR(200) NOT NULL,
    Email        NVARCHAR(200),
    Balance      DECIMAL(18,2) DEFAULT 0,
    SysStartTime DATETIME2 GENERATED ALWAYS AS ROW START NOT NULL,
    SysEndTime   DATETIME2 GENERATED ALWAYS AS ROW END NOT NULL,
    PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime)
)
WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.CustomerHistory));

SQL Server creates dbo.CustomerHistory automatically with the row versions. But CustomerHistory does not include ChangedBy or ChangeReason.

2) Add audit metadata (who/why)

You can store user info in a separate audit table or extend approach by writing triggers to insert augmented history or use an application-level write to an audit table.

Example: keep CustomerHistoryMeta where you store (HistoryRowPK, ChangedBy, ChangeReason, TxId) linked to history rows using SysStartTime and CustomerId as keys.

3) Query history (time travel)

-- Get record as of a point in time
SELECT * 
FROM dbo.Customer
FOR SYSTEM_TIME AS OF '2025-11-01 10:00:00'
WHERE CustomerId = '...';

-- Get all versions
SELECT * FROM dbo.Customer
FOR SYSTEM_TIME ALL
WHERE CustomerId = '...'
ORDER BY SysStartTime;

4) Revert to historical version (pattern)

To revert, read the historical row and insert a new current row (or update current row) — do not “restore” history row directly; write a new change so revert is also tracked.

Option B — Manual history table + trigger (flexible, full metadata)

1) Schema (example for table Customer)

CREATE TABLE dbo.Customer
(
    CustomerId UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
    Name NVARCHAR(200),
    Email NVARCHAR(200),
    Balance DECIMAL(18,2)
);

CREATE TABLE dbo.CustomerHistory
(
    HistoryId BIGINT IDENTITY PRIMARY KEY,
    CustomerId UNIQUEIDENTIFIER NOT NULL,
    ChangeType CHAR(1) NOT NULL, -- I/U/D
    ChangedBy NVARCHAR(200) NULL,
    ChangeReason NVARCHAR(500) NULL,
    ChangedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
    PayloadJson NVARCHAR(MAX) NOT NULL, -- full row snapshot as JSON
    TxId UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID()
);

CREATE INDEX IX_CustomerHistory_CustomerId ON dbo.CustomerHistory(CustomerId);
CREATE INDEX IX_CustomerHistory_TxId ON dbo.CustomerHistory(TxId);

2) Trigger to capture changes

CREATE TRIGGER trg_Customer_Audit
ON dbo.Customer
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @txid UNIQUEIDENTIFIER = NEWID();
    DECLARE @changedBy NVARCHAR(200) = SUSER_SNAME(); -- replace via CONTEXT_INFO if app sets

    -- INSERTED rows -> Insert
    INSERT INTO dbo.CustomerHistory (CustomerId, ChangeType, ChangedBy, ChangeReason, PayloadJson, TxId)
    SELECT i.CustomerId, 'I', @changedBy, NULL, (SELECT i.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), @txid
    FROM inserted i;

    -- UPDATED rows -> Update (capture new snapshot or both old & new as needed)
    INSERT INTO dbo.CustomerHistory (CustomerId, ChangeType, ChangedBy, ChangeReason, PayloadJson, TxId)
    SELECT u.CustomerId, 'U', @changedBy, NULL, (SELECT u.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), @txid
    FROM inserted u
    WHERE EXISTS (SELECT 1 FROM deleted d WHERE d.CustomerId = u.CustomerId);

    -- DELETED rows -> Delete
    INSERT INTO dbo.CustomerHistory (CustomerId, ChangeType, ChangedBy, ChangeReason, PayloadJson, TxId)
    SELECT d.CustomerId, 'D', @changedBy, NULL, (SELECT d.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), @txid
    FROM deleted d;
END

Notes

3) Passing application user into trigger

Before executing DML, set session context from application:

EXEC sp_set_session_context 'AppUser', '[email protected]';

Then inside trigger:

DECLARE @changedBy NVARCHAR(200) = CONVERT(NVARCHAR(200), SESSION_CONTEXT(N'AppUser'));

This makes ChangedBy accurate.

4) Stored procedures to query history

CREATE PROCEDURE usp_GetCustomerHistory
    @CustomerId UNIQUEIDENTIFIER
AS
BEGIN
    SELECT HistoryId, ChangeType, ChangedBy, ChangeReason, ChangedAt, PayloadJson
    FROM dbo.CustomerHistory
    WHERE CustomerId = @CustomerId
    ORDER BY ChangedAt DESC;
END

5) Revert procedure (create new record state from history)

CREATE PROCEDURE usp_RevertCustomerToHistory
    @HistoryId BIGINT,
    @RevertedBy NVARCHAR(200)
AS
BEGIN
    DECLARE @payload NVARCHAR(MAX);
    SELECT @payload = PayloadJson FROM dbo.CustomerHistory WHERE HistoryId = @HistoryId;

    -- parse JSON into columns and update current table
    UPDATE dbo.Customer
    SET Name = JSON_VALUE(@payload, '$.Name'),
        Email = JSON_VALUE(@payload, '$.Email'),
        Balance = TRY_CAST(JSON_VALUE(@payload, '$.Balance') AS DECIMAL(18,2))
    WHERE CustomerId = JSON_VALUE(@payload, '$.CustomerId');

    -- insert a history record marking revert
    INSERT INTO dbo.CustomerHistory (CustomerId, ChangeType, ChangedBy, ChangeReason, PayloadJson, TxId)
    VALUES (JSON_VALUE(@payload, '$.CustomerId'), 'U', @RevertedBy, 'Revert to HistoryId ' + CAST(@HistoryId AS NVARCHAR(20)), @payload, NEWID());
END

Application-level capture (EF Core interceptor) — add user / reason

If you use EF Core, intercept SaveChanges to write audit to history table so you have full contextual data (user id, IP, reason).

Example (simplified)

public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
    private readonly IHttpContextAccessor _http;
    public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http;

    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData,
        InterceptionResult<int> result, CancellationToken cancellationToken = default)
    {
        var ctx = eventData.Context;
        var user = _http.HttpContext?.User?.Identity?.Name ?? "system";
        var entries = ctx.ChangeTracker.Entries().Where(e => e.State == EntityState.Modified
                                                           || e.State == EntityState.Added
                                                           || e.State == EntityState.Deleted);

        foreach (var entry in entries)
        {
            var payload = JsonSerializer.Serialize(entry.CurrentValues.ToObject()); // or build object
            var history = new CustomerHistory
            {
                CustomerId = (Guid)entry.Property("CustomerId").CurrentValue,
                ChangeType = entry.State == EntityState.Added ? "I" : entry.State == EntityState.Deleted ? "D" : "U",
                ChangedBy = user,
                PayloadJson = payload,
                ChangedAt = DateTime.UtcNow,
                TxId = Guid.NewGuid()
            };
            ctx.Set<CustomerHistory>().Add(history);
        }

        return await base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

Register interceptor in Program.cs for EF Core.

Benefits: you have direct access to user principal and request info.

Query patterns & useful API endpoints

Example C# controller methods using Dapper/EF Core — omitted for brevity (pattern same as stored procs).

Field-level diff (practical approach)

Example diff function (C# pseudocode)

Dictionary<string, object> left = JsonSerializer.Deserialize<Dictionary<string, object>>(leftJson);
Dictionary<string, object> right = JsonSerializer.Deserialize<Dictionary<string, object>>(rightJson);
var diffs = new List<Diff>();
foreach(var key in left.Keys.Union(right.Keys))
{
    left.TryGetValue(key, out var lv);
    right.TryGetValue(key, out var rv);
    if (!object.equals(lv, rv))
        diffs.Add(new Diff { Field = key, Old = lv?.ToString(), New = rv?.ToString() });
}

Return diffs to UI.

Retention, compression and archiving

Concurrency, transaction and tx-id handling

Example in application

EXEC sp_set_session_context @key = 'TxId', @value = '...';

Trigger reads

DECLARE @txid UNIQUEIDENTIFIER = CONVERT(uniqueidentifier, SESSION_CONTEXT(N'TxId'));

Security & GDPR considerations

Performance considerations

Testing checklist

Example migration & scripts (summary)

  1. Add history table schema for each entity.

  2. Add triggers or enable temporal versioning.

  3. Add SyncWatermark table if you need cross-system sync.

  4. Add stored procedures to fetch history, revert, cleanup/archive.

  5. Add application interceptor to set SESSION_CONTEXT('AppUser') and optional TxId.

  6. Build APIs for UI and admin tasks.

Final recommendations