AI News Hub Logo

AI News Hub

Change tracking and soft delete: audit trails without the boilerplate

DEV Community
David Lastrucci

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. Attribute Set on Required field type [TCreatedAt] Insert TTNullable [TCreatedBy] Insert String [TUpdatedAt] Update TTNullable [TUpdatedBy] Update String [TDeletedAt] Delete TTNullable [TDeletedBy] Delete String You add them to your entity fields, and Trysil fills them in automatically. 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; [TCreatedBy] [TColumn('CreatedBy')] FCreatedBy: String; [TUpdatedAt] [TColumn('UpdatedAt')] FUpdatedAt: TTNullable; [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 read FCreatedAt; property CreatedBy: String read FCreatedBy; property UpdatedAt: TTNullable read FUpdatedAt; property UpdatedBy: String read FUpdatedBy; end; Now when you insert or update: var LArticle: TTArticle; begin LArticle := LContext.CreateEntity(); LArticle.Title := 'Getting started with Trysil'; LArticle.Body := 'In this article...'; LContext.Insert(LArticle); // CreatedAt is now set to the current timestamp // CreatedBy is set to the current user name end; LArticle.Title := 'Getting started with Trysil (updated)'; LContext.Update(LArticle); // UpdatedAt is now set to the current timestamp // UpdatedBy is set to the current user name // CreatedAt and CreatedBy remain unchanged 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; 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. 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; [TDeletedBy] [TColumn('DeletedBy')] FDeletedBy: String; [TColumn('VersionID')] [TVersionColumn] FVersionID: TTVersion; public // ... properties ... property DeletedAt: TTNullable read FDeletedAt; property DeletedBy: String read FDeletedBy; end; When an entity has a [TDeletedAt] field, calling Delete 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 The record stays in the database, but it is marked as deleted. 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(LArticles); Sometimes you need to see deleted records (admin panels, audit logs). Use the filter builder: var LBuilder: TTFilterBuilder; LFilter: TTFilter; begin LBuilder := LContext.CreateFilterBuilder(); try LFilter := LBuilder .IncludeDeleted .OrderByDesc('DeletedAt') .Build; LContext.Select(LAllArticles, LFilter); finally LBuilder.Free; end; end; 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. 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 ); And the complete entity lifecycle: var LArticle: TTArticle; begin // Create LArticle := LContext.CreateEntity(); LArticle.Title := 'My article'; LArticle.Body := 'Content here'; LContext.Insert(LArticle); // CreatedAt = 2026-04-09 10:30:00, CreatedBy = 'david.lastrucci' // Update LArticle.Title := 'My article (revised)'; LContext.Update(LArticle); // UpdatedAt = 2026-04-09 11:15:00, UpdatedBy = 'david.lastrucci' // Soft delete LContext.Delete(LArticle); // DeletedAt = 2026-04-09 14:00:00, DeletedBy = 'david.lastrucci' // Record is still in the database, but invisible to normal queries end; Over these six articles we have covered the core of Trysil: First contact — entity, connection, CRUD Entity mapping — attributes, types, nullable fields, optimistic locking Validation — declarative rules, custom validators, error handling Filtering — fluent query builder, sorting, pagination Relations — lazy loading, cascade delete, parent-child patterns 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. 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!