Architecture First: Building a Jira Alternative in a Tiny Team, AI-Assisted — Part 20.
In the previous article tasks got attributes. When several people can change a task, sooner or later someone asks: who changed this, and when?
The short version
How we built the change history of tasks:
- The history lives in two tables: a record of the action (who, when, which entity, what was done) and the list of changed fields with their old and new values.
- The service that changes the task writes the records itself, in the same transaction. If the change is not saved, there is no history record either, and vice versa.
- EF Core interceptors and temporal tables did not fit: deciding what counts as a change and how to show it is business logic, and it belongs neither in an interceptor nor in the database.
- Event sourcing did not fit either. It needs an event bus, which we do not have, and with it the data can differ from the history for a while. We needed the history to always reflect exactly what happened.
- The history stores values in the form they are shown in, so it stays readable even if an attribute is renamed or deleted later.
- The table is called
OrganizationLog, not "issue history", because later we want to keep changes of other entities in it too, not only of tasks.
Why a task needs a history
While one person uses the tasks, the question "who changed this" does not come up. In an organization several members work on the same task: someone moves it to another status, someone changes the assignee or an attribute, someone removes an attachment. Without a history there is no way to understand how the task got to its current state.
We wanted the history to answer that question honestly: what it records is what really happened.
The model: an action record and the changed fields
One user action is one record in OrganizationLog:
public class OrganizationLog
{
public long Id { get; set; }
public long? EntityId { get; set; }
public LogEntityType EntityType { get; set; }
public long OrganizationId { get; set; }
public Organization? Organization { get; set; }
public Guid OwnerId { get; set; }
public User? Owner { get; set; }
public DateTime CreatedAt { get; set; }
public LogAction Action { get; set; }
public List<OrganizationLogItem>? Items { get; set; }
}
public enum LogEntityType
{
Issue,
Comment,
}
public enum LogAction
{
Create,
Update,
Delete,
}
If one action changed several fields, such as the text, the assignee and two attributes, the record gets several OrganizationLogItem elements:
public class OrganizationLogItem
{
public long Id { get; set; }
public long OrganizationLogId { get; set; }
public OrganizationLog? OrganizationLog { get; set; }
public PropertyType PropertyType { get; set; }
[MaxLength(36)]
public string? ParentId { get; set; }
[MaxLength(36)]
public string? OldValueId { get; set; }
[MaxLength(36)]
public string? NewValueId { get; set; }
[MaxLength(255)]
public string? PropertyName { get; set; }
[MaxLength(4096)]
public string? OldDisplayValue { get; set; }
[MaxLength(4096)]
public string? NewDisplayValue { get; set; }
}
PropertyType defines what exactly changed: the text, the status, the assignee, an attribute, an attachment, the board or the space. For references to other entities their id is stored (OldValueId, NewValueId), and next to it the value as the user saw it (OldDisplayValue, NewDisplayValue).
Ids alone are not enough: the history should show what the user saw at the moment of the change. If it stored only the id of a list option, then after the option was renamed old records would show the new name, and after it was deleted they would show an empty spot. So the text of the value is saved next to the id, and PropertyName keeps the attribute name as it was at the moment of the change.
We named the table OrganizationLog, not IssueHistory, because it is not tied to tasks: every record stores the entity type. Today it holds changes of tasks and comments, and when other entities need a history, it will be stored there too.
How we record the history
For the history to match what happened, it has to be saved together with the change itself, in one transaction. If the change rolls back, there must be no history record.
There are several common ways to keep a history. Here is why they did not fit us.
An EF Core interceptor. You can intercept SaveChanges, look in the ChangeTracker for changed properties and record them. But an interceptor sees only columns. It does not know that a change of StatusId is a move to another status that should be shown by the status name, that an attribute change lives in a separate value table, or that the attribute has a name. Deciding what counts as a change and how to show it is business logic, and an interceptor is not the place for it.
Temporal tables. The database itself keeps previous versions of rows. It is the same problem, only one level lower: instead of a history of changes you get copies of values, and the logic of what actually happened ends up at the database level.
Event sourcing. Changes are written as events, and both the current state and the history are built from them. This approach works well when the app already has an event bus and when the history and the data are allowed to be out of sync for a while. We needed the history to always match the data, so it has to be saved in the same transaction as the change itself.
So the history is written by the service that changes the data.
What it looks like in code
The work is split the same way as in the rest of the backend: the host service checks permissions and manages the transaction, and the Core service changes the data (more in the article about our engineering principles). There we also wrote that creating an issue must add a history record whether the request came from the Telegram bot or the web API. That is why the history is written in the Core service.
The host service opens the transaction and commits it when everything is ready:
await using var transaction = await context.Database.BeginTransactionAsync(ct);
await issuesService.Update(
issueId,
request.AuthData.UserId,
request.Content,
request.AssigneeId,
attributeUpdateRequests,
uploadedFiles,
request.RemoveAttachmentIds,
ct);
await transaction.CommitAsync(ct);
The Core service compares the old and new values, changes only what changed, and adds a history item for every change:
var change = new OrganizationLog
{
EntityId = issueId,
EntityType = LogEntityType.Issue,
Action = LogAction.Update,
OwnerId = updaterId,
CreatedAt = date,
Items = [],
};
if (oldContent != content)
{
settersBuilder += builder => builder.SetProperty(x => x.Content, content);
change.Items.Add(GetContentLogItem(oldContent, content));
}
// other changes (assignee, attachments) are processed the same way
change.Items.AddRange(await UpdateAttributes(issueId, issueData.OrganizationId, attributes, cancellationToken));
if (change.Items.Count != 0)
{
context.Add(change);
await context.SaveChangesAsync(cancellationToken);
}
The methods that change attachments and attributes return their own history items, and the record is saved only if something actually changed. Saving a task without changes leaves no empty records in the history.
What the user sees
In the web app the task page got a "History" tab next to the comments. Each record shows who did what and when, and inside it the changed fields with their old and new values. Creating and deleting a task also end up in the history, as do changes to comments. The history UI was built by Nikolay, the frontend developer who had joined the project by then.
Results
- The history is what really happened. The history record and the change itself are saved in one transaction.
- The service writes the history, not the infrastructure. What counts as a change and how to show it is business logic, so neither EF Core interceptors nor temporal tables fit.
- No event sourcing. We have no event bus, and the history drifting from the data would be unacceptable.
- Display values are saved in the history. That keeps it readable after attributes are renamed or deleted.
- One table for all entities. Tasks and comments write to it today, other entities will later.
What comes next
Shortly before the history, the web app got the ability to attach images to tasks. That is the subject of the next article.