DEV Community

David Lastrucci
David Lastrucci

Posted on

Change tracking and soft delete: audit trails without the boilerplate

In most business applications, you need to answer questions like: Who created this record? When was it last modified? Can we undo this deletion?

Implementing this by hand means adding columns, writing triggers or hooks, and remembering to update them on every operation. Trysil does it with six attributes.

The change tracking attributes

Attribute Set on Required field type
[TCreatedAt] Insert TTNullable<TDateTime>
[TCreatedBy] Insert String
[TUpdatedAt] Update TTNullable<TDateTime>
[TUpdatedBy] Update String
[TDeletedAt] Delete TTNullable<TDateTime>
[TDeletedBy] Delete String

You add them to your entity fields, and Trysil fills them in automatically.

Adding an audit trail

unit Article.Model;

{$WARN UNKNOWN_CUSTOM_ATTRIBUTE ERROR}

interface

uses
  Trysil.Types,
  Trysil.Attributes,
  Trysil.Validation.Attributes;

type
  [TTable('Articles')]
  [TSequence('ArticlesID')]
  TTArticle = class
  strict private
    [TPrimaryKey]
    [TColumn('ID')]
    FID: TTPrimaryKey;

    [TRequired]
    [TMaxLength(200)]
    [TColumn('Title')]
    FTitle: String;

    [TColumn('Body')]
    FBody: String;

    [TCreatedAt]
    [TColumn('CreatedAt')]
    FCreatedAt: TTNullable<TDateTime>;

    [TCreatedBy]
    [TColumn('CreatedBy')]
    FCreatedBy: String;

    [TUpdatedAt]
    [TColumn('UpdatedAt')]
    FUpdatedAt: TTNullable<TDateTime>;

    [TUpdatedBy]
    [TColumn('UpdatedBy')]
    FUpdatedBy: String;

    [TColumn('VersionID')]
    [TVersionColumn]
    FVersionID: TTVersion;
  public
    property ID: TTPrimaryKey read FID;
    property Title: String read FTitle write FTitle;
    property Body: String read FBody write FBody;
    property CreatedAt: TTNullable<TDateTime> read FCreatedAt;
    property CreatedBy: String read FCreatedBy;
    property UpdatedAt: TTNullable<TDateTime> read FUpdatedAt;
    property UpdatedBy: String read FUpdatedBy;
  end;
Enter fullscreen mode Exit fullscreen mode

Now when you insert or update:

var
  LArticle: TTArticle;
begin
  LArticle := LContext.CreateEntity<TTArticle>();
  LArticle.Title := 'Getting started with Trysil';
  LArticle.Body := 'In this article...';
  LContext.Insert<TTArticle>(LArticle);
  // CreatedAt is now set to the current timestamp
  // CreatedBy is set to the current user name
end;
Enter fullscreen mode Exit fullscreen mode
LArticle.Title := 'Getting started with Trysil (updated)';
LContext.Update<TTArticle>(LArticle);
// UpdatedAt is now set to the current timestamp
// UpdatedBy is set to the current user name
// CreatedAt and CreatedBy remain unchanged
Enter fullscreen mode Exit fullscreen mode

Providing the current user

The *By fields need to know who the current user is. You provide this via a callback on TTContext:

LContext.OnGetCurrentUser := function: String
begin
  result := 'david.lastrucci';
end;
Enter fullscreen mode Exit fullscreen mode

In a real application, this might read from an authentication token, a session variable, or a thread-local user context. If you do not assign the callback, Trysil writes an empty string.

Soft delete

Traditional DELETE removes the row from the database. In many scenarios — audit compliance, undo functionality, data recovery — you want to keep the record but mark it as deleted.

Trysil supports this natively. Add [TDeletedAt] (and optionally [TDeletedBy]) to your entity:

  [TTable('Articles')]
  [TSequence('ArticlesID')]
  TTArticle = class
  strict private
    // ... other fields ...

    [TDeletedAt]
    [TColumn('DeletedAt')]
    FDeletedAt: TTNullable<TDateTime>;

    [TDeletedBy]
    [TColumn('DeletedBy')]
    FDeletedBy: String;

    [TColumn('VersionID')]
    [TVersionColumn]
    FVersionID: TTVersion;
  public
    // ... properties ...
    property DeletedAt: TTNullable<TDateTime> read FDeletedAt;
    property DeletedBy: String read FDeletedBy;
  end;
Enter fullscreen mode Exit fullscreen mode

What changes

When an entity has a [TDeletedAt] field, calling Delete<T> no longer executes a SQL DELETE. Instead it executes:

UPDATE Articles
SET DeletedAt = :DeletedAt, DeletedBy = :DeletedBy, VersionID = VersionID + 1
WHERE ID = :ID AND VersionID = :VersionID
Enter fullscreen mode Exit fullscreen mode

The record stays in the database, but it is marked as deleted.

Automatic exclusion

All SELECT queries on that entity automatically add DeletedAt IS NULL to the WHERE clause. Soft-deleted records are invisible by default — your application code does not need to change at all.

// This only returns non-deleted articles
LContext.SelectAll<TTArticle>(LArticles);
Enter fullscreen mode Exit fullscreen mode

Including deleted records

Sometimes you need to see deleted records (admin panels, audit logs). Use the filter builder:

var
  LBuilder: TTFilterBuilder<TTArticle>;
  LFilter: TTFilter;
begin
  LBuilder := LContext.CreateFilterBuilder<TTArticle>();
  try
    LFilter := LBuilder
      .IncludeDeleted
      .OrderByDesc('DeletedAt')
      .Build;

    LContext.Select<TTArticle>(LAllArticles, LFilter);
  finally
    LBuilder.Free;
  end;
end;
Enter fullscreen mode Exit fullscreen mode

Relation checks and soft delete

When you soft-delete a parent record, Trysil skips the child relation check. This makes sense: the record is not being physically removed, so foreign key integrity is preserved.

Putting it all together

Here is the SQL table that supports full change tracking with soft delete:

CREATE TABLE Articles (
  ID INTEGER PRIMARY KEY,
  Title TEXT NOT NULL,
  Body TEXT,
  CreatedAt TEXT,
  CreatedBy TEXT,
  UpdatedAt TEXT,
  UpdatedBy TEXT,
  DeletedAt TEXT,
  DeletedBy TEXT,
  VersionID INTEGER NOT NULL DEFAULT 1
);
Enter fullscreen mode Exit fullscreen mode

And the complete entity lifecycle:

var
  LArticle: TTArticle;
begin
  // Create
  LArticle := LContext.CreateEntity<TTArticle>();
  LArticle.Title := 'My article';
  LArticle.Body := 'Content here';
  LContext.Insert<TTArticle>(LArticle);
  // CreatedAt = 2026-04-09 10:30:00, CreatedBy = 'david.lastrucci'

  // Update
  LArticle.Title := 'My article (revised)';
  LContext.Update<TTArticle>(LArticle);
  // UpdatedAt = 2026-04-09 11:15:00, UpdatedBy = 'david.lastrucci'

  // Soft delete
  LContext.Delete<TTArticle>(LArticle);
  // DeletedAt = 2026-04-09 14:00:00, DeletedBy = 'david.lastrucci'
  // Record is still in the database, but invisible to normal queries
end;
Enter fullscreen mode Exit fullscreen mode

Series recap

Over these six articles we have covered the core of Trysil:

  1. First contact — entity, connection, CRUD
  2. Entity mapping — attributes, types, nullable fields, optimistic locking
  3. Validation — declarative rules, custom validators, error handling
  4. Filtering — fluent query builder, sorting, pagination
  5. Relations — lazy loading, cascade delete, parent-child patterns
  6. Change tracking — audit trails, soft delete, automatic exclusion

Trysil also includes a JSON serialization module, an HTTP/REST hosting module with attribute-based routing and JWT authentication, and a Unit of Work pattern via TTSession<T>. These are topics for future articles.

If you want to explore further, the GitHub repo contains full demo projects, a cookbook with 17 copy-paste recipes, and complete API documentation.


Trysil is open-source and available on GitHub. If this series helped you, consider giving the project a star — it helps other Delphi developers discover it!

Top comments (0)