Enterprises often need to capture what changed, who changed it, when, and why. SQL Server CDC is great, but sometimes you cannot use it (policy, licensing, cloud limits), or you want finer control, richer metadata, or different retention/processing rules. In that case, building your own Change Tracking Engine inside the application and database is a sensible approach.

This article gives a production-quality blueprint: data model, trigger patterns, lightweight transaction-safe logging, background processing, API surface, UI for audit/undo, retention, performance and testing. Examples are in SQL Server, ASP.NET Core (.NET 8), and Angular. The pattern works with other RDBMS with minor adjustments.

Goals and constraints

Your engine should:

Constraints we accept:

High-level design

Application/API (Angular + .NET)
        |
        v
Write to Business Tables (INSERT/UPDATE/DELETE)
        |
   SQL TRIGGERS (lightweight)
        |
Append row(s) to ChangeLog tables (transactional or queued)
        |
Background Processor (Worker / Service)
 - read ChangeLog (watermark)
 - validate/enrich
 - project to read models / push to queues
        |
 Downstream: Audit UI, Search Index, ETL, Kafka, Undo API

Key idea: append-only changelog as source of truth for changes. Triggers must be lightweight; heavy enrichment or publishing is done by worker.

Data model

Keep the change store normalized and compact. Example schema:

-- A change set groups related changes (single API call, multiple rows)
CREATE TABLE ChangeSet (
  ChangeSetId BIGINT IDENTITY PRIMARY KEY,
  SourceSystem VARCHAR(100),        -- "WebAPI", "ImportJob", "IntegrationX"
  CorrelationId UNIQUEIDENTIFIER,   -- request id / trace id
  CreatedBy VARCHAR(200),           -- user id or service account
  CreatedAt DATETIME2 DEFAULT SYSUTCDATETIME(),
  Processed BIT DEFAULT 0,
  ProcessedAt DATETIME2 NULL
);

-- Each row change
CREATE TABLE ChangeLog (
  ChangeId BIGINT IDENTITY PRIMARY KEY,
  ChangeSetId BIGINT NOT NULL REFERENCES ChangeSet(ChangeSetId),
  TableName SYSNAME NOT NULL,
  PrimaryKeyJson NVARCHAR(4000) NOT NULL,   -- {"Id":123}
  Operation CHAR(1) NOT NULL,               -- 'I','U','D'
  BeforeJson NVARCHAR(MAX) NULL,
  AfterJson NVARCHAR(MAX) NULL,
  ChangedBy VARCHAR(200) NULL,
  ChangedAt DATETIME2 DEFAULT SYSUTCDATETIME(),
  SequenceNo BIGINT NOT NULL DEFAULT 0      -- ordering within set
);

CREATE INDEX IX_ChangeLog_Processed ON ChangeSet(Processed, CreatedAt);
CREATE INDEX IX_ChangeLog_Table ON ChangeLog(TableName, ChangedAt);

Notes

Trigger strategy (transaction-safe and lightweight)

Two common approaches:

  1. Synchronous trigger writes — trigger writes ChangeSet and ChangeLog rows inside same transaction. Pros: perfect atomicity. Cons: extra I/O inside transaction, may affect latency.

  2. Async queue from trigger — trigger writes minimal row into small queue table or Service Broker, worker reads and expands. Pros: minimal transaction cost. Cons: small window where change detail is queued asynchronously.

Recommendation: Use synchronous minimal append where each trigger inserts compact JSON into ChangeLog. Keep serialization small and avoid heavy computations. If write latency is critical, use a fast queue table and let worker gather details and write main change table.

Example trigger pattern (insert/update/delete)

Assume table Customer(CustomerId PK, Name, Email, Phone, ModifiedAt, ModifiedBy).

CREATE PROCEDURE dbo.AppendChangeSet
  @SourceSystem VARCHAR(100),
  @CorrelationId UNIQUEIDENTIFIER,
  @CreatedBy VARCHAR(200),
  @TableName SYSNAME,
  @PrimaryKeyJson NVARCHAR(4000),
  @Operation CHAR(1),
  @BeforeJson NVARCHAR(MAX),
  @AfterJson NVARCHAR(MAX)
AS
BEGIN
  SET NOCOUNT ON;
  DECLARE @ChangeSetId BIGINT;

  -- Option A: one ChangeSet per transaction/request; Use CONTEXT_INFO or session var for reusing ChangeSet
  INSERT INTO ChangeSet (SourceSystem, CorrelationId, CreatedBy)
  VALUES (@SourceSystem, @CorrelationId, @CreatedBy);

  SET @ChangeSetId = SCOPE_IDENTITY();

  INSERT INTO ChangeLog (ChangeSetId, TableName, PrimaryKeyJson, Operation, BeforeJson, AfterJson, ChangedBy)
  VALUES (@ChangeSetId, @TableName, @PrimaryKeyJson, @Operation, @BeforeJson, @AfterJson, @CreatedBy);
END

Trigger for UPDATE:

CREATE TRIGGER TR_Customer_Update
ON dbo.Customer
AFTER UPDATE
AS
BEGIN
  SET NOCOUNT ON;

  DECLARE @CorrelationId UNIQUEIDENTIFIER = CONVERT(UNIQUEIDENTIFIER, SESSION_CONTEXT(N'CorrelationId'));
  DECLARE @SourceSystem VARCHAR(100) = SESSION_CONTEXT(N'SourceSystem');
  DECLARE @User VARCHAR(200) = SESSION_CONTEXT(N'User') ;

  IF @CorrelationId IS NULL
  BEGIN
    SET @CorrelationId = NEWID(); -- fallback
  END

  INSERT INTO ChangeLog(ChangeSetId, TableName, PrimaryKeyJson, Operation, BeforeJson, AfterJson, ChangedBy, ChangedAt, SequenceNo)
  SELECT
    NULL, -- if you prefer creating ChangeSet in worker, else set ChangeSetId via AppendChangeSet (recommended)
    'Customer',
    JSON_QUERY('{"CustomerId":' + CONVERT(NVARCHAR(50), d.CustomerId) + '}'),
    'U',
    (SELECT d.CustomerId, d.Name, d.Email, d.Phone FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
    (SELECT i.CustomerId, i.Name, i.Email, i.Phone FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
    ISNULL(@User, SUSER_SNAME()),
    SYSUTCDATETIME(),
    ROW_NUMBER() OVER (ORDER BY (SELECT 1))  -- sequence if multiple rows
  FROM deleted d
  JOIN inserted i ON d.CustomerId = i.CustomerId;
END;

Notes

Application pattern: set session context

In .NET (EF Core or Dapper), on opening connection call:

await connection.ExecuteAsync(
  "EXEC sp_set_session_context @key, @value",
  new { key = "CorrelationId", value = correlationId.ToString() });

await connection.ExecuteAsync(
  "EXEC sp_set_session_context @key, @value",
  new { key = "User", value = currentUserId });

Wrap this in a DB-context interceptor so every request carries context.

Background processing — Change Processor

The worker reads unprocessed ChangeSets or ChangeLog rows and performs heavy tasks:

Important patterns

Example worker (C# simplified)

public async Task ProcessChangesAsync(CancellationToken ct)
{
  while (!ct.IsCancellationRequested)
  {
    var sets = await _db.QueryAsync<ChangeSetDto>(
      @"WITH cte AS (
           SELECT TOP (@batch) * FROM ChangeSet WHERE Processed = 0 ORDER BY ChangeSetId
         )
         UPDATE cte SET Processed = 2 OUTPUT inserted.*;",
      new { batch = 100 });

    foreach (var set in sets)
    {
       var changes = await _db.QueryAsync<ChangeLogDto>(
         "SELECT * FROM ChangeLog WHERE ChangeSetId = @cs ORDER BY SequenceNo",
         new { cs = set.ChangeSetId });

       // transform/enrich
       // publish to queue or index
       // mark processed
       await _db.ExecuteAsync("UPDATE ChangeSet SET Processed = 1, ProcessedAt = SYSUTCDATETIME() WHERE ChangeSetId = @id",
                              new { id = set.ChangeSetId });
    }

    await Task.Delay(TimeSpan.FromSeconds(1), ct);
  }
}

Notes

Downstream consumers & idempotency

Publish messages with metadata:

{
  "ChangeSetId": 123,
  "ChangeId": 456,
  "Table": "Customer",
  "Operation": "U",
  "PrimaryKey": {"CustomerId": 99},
  "Before": {...},
  "After": {...},
  "ChangedBy":"user@company",
  "ChangedAt": "2025-11-20T12:00:00Z"
}

Consumers should store last processed ChangeId per source to avoid duplicate processing. Use unique message key (ChangeId) if using Kafka or dedupe store in relational DB.

Undo & Reconciliation

Undo support can be implemented by re-applying inverse operations using BeforeJson. Example undo for UPDATE is to write a new UPDATE using BeforeJson as After. For DELETE, re-insert using BeforeJson. For INSERT, delete the row.

Careful rules

Implement an Undo API in .NET that:

  1. Takes ChangeSetId (or ChangeId)

  2. Loads related ChangeLog entries in reverse order (LIFO)

  3. For each change compute inverse operation and apply inside a transaction with appropriate concurrency checks

  4. Log the undo as a new ChangeSet with references to original ChangeSet.

Retention & legal hold

Retention is essential

Add columns

ALTER TABLE ChangeSet ADD RetainUntil DATETIME2 NULL, LegalHold BIT DEFAULT 0;

Purge job

DELETE FROM ChangeLog WHERE ChangeSetId IN (
  SELECT ChangeSetId FROM ChangeSet 
  WHERE Processed = 1 AND IsDeleted = 0 AND (LegalHold = 0 OR LegalHold IS NULL)
    AND CreatedAt < DATEADD(day, -@RetentionDays, SYSUTCDATETIME())
);

Performance considerations

Alternative: Application-level change logging

Instead of triggers, application code can write change log entries. Benefits:

Downside: every code path must call the logging API (including ETL/imports). But you can centralize logging in repository layer or use interceptors (EF Core SaveChanges interceptor).

Example EF Core SaveChanges interceptor

public override async Task<int> SaveChangesAsync(...)
{
  var entries = ChangeTracker.Entries()
    .Where(e => e.State == EntityState.Modified || e.State == EntityState.Added || e.State == EntityState.Deleted);

  var changeSetId = await CreateChangeSetAsync(...);

  foreach (var e in entries) {
     var before = Serialize(e.OriginalValues);
     var after = Serialize(e.CurrentValues);
     await AppendChangeLogAsync(changeSetId, e.Entity.GetType().Name, pkJson, op, before, after);
  }

  return await base.SaveChangesAsync(...);
}

This approach often simplifies debugging and avoids DB triggers.

Angular UI: audit explorer & undo

Provide components:

Example Angular API call

getChangeSets(filter) {
  return this.http.post('/api/changesets/query', filter);
}

Display diff using a JSON diff library and highlight changed paths.

Testing strategy

Observability & monitoring

Track metrics:

Emit traces and correlation IDs from application through session context to change log so you can trace a UI action end-to-end.

Security & compliance

Real-world patterns & gotchas

Quick checklist before rollout

  1. Implement session context propagation in all DB connections.

  2. Create compact JSON snapshots (only changed fields where possible).

  3. Ensure triggers are minimal (append-only).

  4. Create robust background processor with idempotency and batch processing.

  5. Add monitoring/alerts for processing lag.

  6. Implement retention and legal hold policies.

  7. Write tests: functional, load, failure injection.

  8. Secure the API and logs.

Conclusion

A well-designed change tracking engine without CDC gives you full control: atomic change capture, custom metadata, grouping of changes, undo support, and flexible downstream processing. The pattern combines lightweight triggers or application interceptors, an append-only change store, and a robust background processor that enriches and publishes changes.