How it’s built
The GTD Task Manager stores history, not state
A normal task app keeps one row per task and overwrites it on every edit — the past is lost. This tool never updates a task in place. Each change is an immutable event appended to that task’s stream, and the board you see is a projection rebuilt from those events.
The events
The whole domain is five facts that can happen to a task. The aggregate emits them; it never exposes setters.
| Event | Emitted when | Payload |
|---|---|---|
| ItemCaptured | You capture a thought into the inbox. | title |
| ItemClarified | You decide the list and context it belongs on. | status, context |
| ItemReworded | You rename the task. | title |
| ItemCompleted | You finish it. | — |
| ItemDropped | You bin it. | — |
The write model: rebuilt by replay
There is no ORM row for a task. To handle a command, the repository loads the event stream and folds it into current state with a pure evolve function. The command is then validated against that rebuilt state — an inbox item can’t be completed, a closed item can’t be re-clarified — and, if accepted, produces new events.
function evolve(state, event) {
switch (event.type) {
case 'ItemCaptured': return { exists: true, title: event.title, status: 'inbox' };
case 'ItemClarified': return { ...state, status: event.status, context: event.context };
case 'ItemCompleted': return { ...state, status: 'done' };
// …
}
}
const state = events.reduce(evolve, NO_ITEM); // replay the whole stream
complete(state); // throws unless the item is actionableBecause setup is just a list of prior events, the aggregate is unusually easy to test: given these events, when this command, then those new events (or a rejection).
The read model: a projection (CQRS)
You can’t SQL-query an append-only log for “all next actions grouped by list”. So the write side and the read side are separated — Command Query Responsibility Segregation. When events are appended, a projector applies them to a denormalized gtd_item_view table in the same database transaction. The board reads that table, so it always reflects your own writes — no eventual-consistency lag for a single user.
withTransaction(async (client) => {
for (const event of newEvents) insertEvent(client, event); // append-only log
await applyToItemView(client, userId, itemId, newEvents); // update the projection
});Optimistic concurrency
Every event carries a per-task version, unique in the store. An append expects the version it read; if another request slipped in first, the unique constraint rejects the write instead of silently clobbering it. No row locks, no lost updates.
UNIQUE (aggregate_id, version) -- two writers at version N → one wins, the other retries
Why this matters beyond tasks
The same architecture — an immutable event log as the source of truth, aggregates rebuilt by replay, and projections tailored per query — is how serious systems model money movements, order lifecycles, and audit-critical workflows. A GTD list is just the smallest honest demo: you get a perfect audit trail and time-travel for free, and the read side can be reshaped or rebuilt without touching the history.