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:
Record INSERT / UPDATE / DELETE for target tables.
Capture minimal “before” and “after” states, user id, timestamp, source (API/app), and change set id.
Be fast and low-impact on OLTP transactions.
Allow replay, undo (optional), audit reporting and projecting to downstream systems.
Support batching, watermark-based reads, idempotent processors.
Provide retention policies and legal-hold handling.
Avoid CDC, but be reliable and auditable.
Constraints we accept:
No CDC / Change Data Capture.
Prefer simple triggers + append-only change log (or application-level writes).
Avoid long-running work inside triggers; delegate heavy tasks to background worker.
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
ChangeSetgroups changes belonging to same API call or transaction. This is useful for atomic replay and undo.PrimaryKeyJsonis compact, easy to index, and language-agnostic.BeforeJson/AfterJsonstore small JSON snapshots. UseNVARCHAR(MAX)but keep JSON small.SequenceNopreserves order; you can useROW_NUMBER()in trigger code to set sequence.
Trigger strategy (transaction-safe and lightweight)
Two common approaches:
Synchronous trigger writes — trigger writes
ChangeSetandChangeLogrows inside same transaction. Pros: perfect atomicity. Cons: extra I/O inside transaction, may affect latency.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
Use
SESSION_CONTEXTto pass request-specific info from the application to the DB (CorrelationId, User, SourceSystem). This avoids expensive lookups in trigger. In .NET, after opening connection setEXEC sp_set_session_context 'CorrelationId', '...'.FOR JSON PATH, WITHOUT_ARRAY_WRAPPERgives small JSON representation.Keep
ChangeLoginserts minimal; heavy enrichment and publishing is deferred.
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:
Enrich change data (lookup display names, resolve tenant)
Publish to message bus (Kafka, RabbitMQ, Service Bus)
Update read-models / materialized views
Push to search index (Elasticsearch)
Mark ChangeSet processed

Join the conversation! Your thoughts help the community grow.