Code a task tracker with assignees and statuses
Open in the interactive app: practice, quiz, mock interview →In one minute
- A task tracker (think Jira, Asana or Linear at the size of one team) looks like CRUD, but the interview is about three rules: a workflow that decides which status changes are legal, dependencies that block work, and many people editing the same task without silently overwriting each other.
- Model the workflow as data: a transition table per project plus guards ("needs an assignee", "blockers must be done"). A class per status adds files without adding behaviour.
- Give every task a version number and make every write conditional on the version the user saw. That is optimistic concurrency, and it turns a lost update into a visible, recoverable conflict.
- Record every change as an immutable activity event. History, notifications to watchers and audit all read from the same stream.
- The shape is multi-step consistency plus contention on shared records. The same versioned-write idea guards a wiki page, a shopping cart and a Kubernetes object (its
resourceVersion).
Requirements
Functional:
- Create tasks with a title, description, priority (1 is highest), optional due date, creator and optional assignee.
- Move tasks through a workflow:
todo,in_progress,in_review,done, with reopening allowed. Some moves have rules: nobody works on an unassigned task, and a task cannot start or finish while a task blocking it is unfinished. - Mark one task as blocked by another and refuse dependency cycles.
- Limit work in progress: each person may have at most N tasks in
in_progress. - Show a task's full history and list tasks filtered by assignee and status, ordered by priority, then due date.
- Notify watchers when something they follow changes.
Non-functional:
- No lost updates: if two people edit the same task, the second sees a conflict instead of erasing the first change.
- History is append-only and attributable (who, what, when).
- Board and list views return in under 100 ms for projects with tens of thousands of tasks.
Out of scope: real-time collaborative editing of descriptions (see Design Google Docs), time tracking, and running scheduled jobs, which is a different problem covered in Design Task Scheduler.
Entities and relationships
- Project owns tasks, a workflow and a WIP limit. It is the unit of permission and configuration.
- Task has an id, title, priority, due date, status, assignee, version, and the set of task ids that block it. It is the unit of contention.
- User is referenced by id. Users have roles per project (viewer, member, admin), which guards decide on.
- Workflow is a transition table (
fromto allowedtostatuses) plus a list of Guard objects checked before a move. - ActivityEvent is immutable: task id, time, actor, kind and details. Comment is an activity event with text.
- Watcher links a user to a task. NotificationService consumes activity events and decides who hears about them.
- TaskRepository stores tasks and supports conditional saves on version. TaskQuery describes filters and ordering.
A project composes its tasks and workflow. Tasks reference users and other tasks by id rather than holding object pointers: it keeps tasks serialisable, avoids cycles in the object graph, and matches how the database stores them. A subtask is a task with a parent id, so progress roll-ups can treat a task tree as a Composite.
Class diagram
classDiagram
class TaskService {
-repo: TaskRepository
-workflow: Workflow
-clock: Clock
+create(title, creator, priority, due) Task
+assign(id, user, actor, version)
+move(id, status, actor, version)
+addBlocker(id, blockerId)
+query(assignee, status) Task[]
+history(id) ActivityEvent[]
}
class Task {
+id: string
+title: string
+priority: int
+due: int
+status: Status
+assignee: string
+version: int
+blockedBy: Set~string~
}
class Workflow {
-transitions: Map~Status, Status[]~
-guards: Guard[]
+check(task, to, ctx)
}
class Guard {
<<interface>>
+check(task, to, ctx) string
}
class TaskRepository {
<<interface>>
+get(id) Task
+save(task, expectedVersion)
}
class ActivityEvent {
+taskId: string
+at: int
+actor: string
+text: string
}
class NotificationService {
+on(event)
}
TaskService --> TaskRepository
TaskService --> Workflow
TaskService ..> ActivityEvent : appends
Workflow o-- Guard
AssigneeRequired ..|> Guard
BlockersDone ..|> Guard
WipLimit ..|> Guard
TaskRepository o-- Task
NotificationService ..> ActivityEvent : consumesKey flows
Two people open the same task at version 4. Alice moves it to review; Bob, still looking at version 4, tries to reassign it.
sequenceDiagram
participant A as Alice
participant B as Bob
participant S as TaskService
participant W as Workflow
participant R as Repository
participant N as Notifications
A->>S: move(T-7, in_review, v4)
S->>R: get(T-7)
R-->>S: task v4, in_progress
S->>W: check(in_progress to in_review)
W-->>S: ok
S->>R: save(task v5, expected v4)
R-->>S: ok
S->>N: event "moved in_progress->in_review"
B->>S: assign(T-7, carol, v4)
S->>R: get(T-7)
R-->>S: task v5
S-->>B: version conflict: have 5
B->>S: reload, assign(T-7, carol, v5)
S-->>B: ok, v6Design patterns used and why
- State as a table, not as classes. The classic State pattern gives each status its own class. Here statuses have no behaviour of their own; the rules live on the edges between them. A transition table lets each project configure its own workflow (add
qa, removein_review) without code, and it is easy to render as a diagram for users. The State lesson shows the class-per-state form for comparison. - Specification or Chain of guards for the rules on moves. Each guard is small (
AssigneeRequired,BlockersDone,WipLimit,OnlyAdminsReopen) and returns a reason or nothing. The workflow runs them in order and reports the first failure, so new rules arrive as new classes, not as moreifbranches. - Observer through the activity stream. The service appends events; notifications, search indexing and webhooks subscribe. The task code never knows who is listening.
- Repository for storage, with
save(task, expectedVersion)as the only write. This puts the concurrency rule in one place instead of trusting every caller to remember it. - Composite for subtasks when you need roll-ups: a parent's progress is the share of its leaf tasks that are done, computed recursively.
Concurrency and edge cases
- Lost update. Without versions, Alice's status change and Bob's reassignment both read the old row and the last save wins, silently undoing one. A conditional write (
UPDATE task SET ..., version = version + 1 WHERE id = $1 AND version = $2, checking one row changed) turns that into a conflict the client can resolve by reloading. - WIP limit check-then-act. Two moves for the same person can both see "2 of 3 in progress" and both succeed, ending at 4. The count and the move must be checked in one transaction, or the per-user counter must be a row updated conditionally.
- Blocker reopened mid-move. If a blocker is reopened between the guard check and the save, the guard's answer is stale. Either lock the blocker rows for the transaction or accept a narrow window and run a periodic consistency check. Say which, and why.
- Dependency cycles. "A blocks B, B blocks C, C blocks A" deadlocks the work. Before adding an edge, search from the new blocker along its own blockers; if the search reaches the task, refuse. With a few links per task this is microseconds.
- Deleting a blocker. Deleting a task that blocks others should either be refused or remove the edges and record that in the history of the blocked tasks; never leave dangling ids.
- Time zones. Store due dates as UTC instants; sorting local date strings breaks for distributed teams.
- Notification storms. A bulk move of 500 tasks should produce one digest per watcher, not 500 emails. Coalesce events per recipient over a short window.
Down the stack
Layer 1The contract
A write that names version v either applies completely and returns version v + 1, or changes nothing and reports the current version. A move succeeds only along a configured transition and only when every guard passes against the state being written. Every successful change appends exactly one activity event, in the same transaction, so history never disagrees with state. Queries return tasks ordered by priority, then due date (no date last), then creation order, and reflect all committed writes at the time of the read.
Layer 2The mechanism
In a relational store, a task row carries version INT NOT NULL. Writes are conditional updates, and the API exposes the version as an HTTP ETag; a client sends If-Match, and a mismatch returns 412 Precondition Failed as defined in RFC 9110. Kubernetes uses the same idea with metadata.resourceVersion and returns 409 Conflict. The activity table is append-only with an index on (task_id, at). List views use a composite index such as (project_id, assignee_id, status, priority, due_at) so the filter and the sort are served by one B-tree range scan instead of a sort of every row. Blocker edges live in a join table (task_id, blocker_id) indexed both ways, which makes "what blocks me" and "what do I block" equally cheap. The guard for blockers is a single query: count blockers whose status is not done.
Layer 3The machine
A task row with a short title and a dozen fixed fields is a few hundred bytes, so an 8 KB Postgres page holds dozens; a project with 50,000 tasks fits in a few megabytes and stays in the buffer cache. A B-tree lookup on a warm index touches three or four pages, each a memory access of well under a microsecond, so a filtered board query costs more in serialising JSON than in finding rows. A conditional update that commits waits for one write-ahead log flush, roughly 0.1 to 1 ms on NVMe. Version conflicts cost a round trip and a re-render on the client, tens to hundreds of milliseconds, but they are rare because two people rarely edit the same task in the same second. That rarity is the economic case for optimistic over pessimistic locking: no lock is held while a human thinks for 30 seconds in an edit dialog.
Why it's built this way
- People edit in slow dialogs, so holding a lock during an edit would block colleagues for minutes; optimistic versions hold nothing.
- Conflicts are rare but costly when silent, so the write must detect them and the UI must show them.
- Teams disagree on process, so the workflow is configuration, and the rules on it are small pluggable guards.
- Questions like "who moved this and when" come up in every retrospective and audit, so history is an append-only stream written with the change.
- Boards are read far more than written, so indexes match the board's filter and sort exactly.
Extensibility
- Custom workflows per project. Store the transition table and guard list per project; validate on save that every status is reachable from the initial one and that
doneis reachable from every status. - Custom fields. A JSON column with a per-project schema is simpler than an entity-attribute-value table and indexes well enough in Postgres with expression indexes. Validate values against the schema in the service, not in the UI.
- Automation rules. "When a pull request merges, move the task to review" is an activity subscriber that issues commands through the same service, so guards and versions still apply.
- Sprints and boards. A sprint is a time-boxed set of task ids with its own history. Boards are saved queries plus a column mapping from status to column.
- Scale beyond one database. Partition by project id: almost every query and every guard stays within one project, so the partition key matches the access pattern.
Leadership lens
- Build first: the task model with versioned writes, the transition table with two guards, and the activity log written in the same transaction. Notifications and automation can follow.
- Ask in review: where is the version checked; what happens on conflict in the UI; which guards read data that can change concurrently, and is that window acceptable; how are workflows migrated when a project removes a status that tasks still hold.
- Ownership: one team owns the core task service and its invariants; integrations (Git, chat, email) are subscribers owned by others, and they can never bypass the service to write tasks directly.
- Cost: storage is cheap, but notification email and webhook fan-out grow with watchers times events. Batch and rate-limit them from day one.
- In an interview, do not spend 20 minutes on
Epic,StoryandBugsubclasses. Atypefield and per-type workflows cover it.
Check yourself
Why is a transition table usually a better fit than one class per status here?
Show answer
The rules sit on transitions, not in statuses, and they vary per project. A table is configuration that each project can change without code, while classes would need a deploy for every workflow tweak.
Alice and Bob both load task T at version 3. Alice saves first. What does Bob see and why is that better than last-write-wins?
Show answer
Bob's write names version 3, the task is now at 4, so he gets a conflict and must reload. Last-write-wins would silently erase Alice's change, and nobody would know until much later.
How do you detect that adding "A is blocked by B" creates a cycle?
Show answer
Search from B through the tasks that block B, and so on. If the search reaches A (or B is A), the new edge would close a cycle, so refuse it.
The WIP limit is 3. Two moves for the same person arrive at once with 2 in progress. What makes the limit hold?
Show answer
Checking the count and writing the move in one serialised step: a lock or transaction around both, or a conditional update on a per-user counter. Two independent checks both see 2 and both pass.
Watch
- Pessimistic vs Optimistic Concurrency Control in Database Systems (Hussein Nasser): the trade-off behind versioned writes, with database examples.
- State Pattern, Design Patterns ep 17 (Christopher Okhravi): the class-per-state form, useful to contrast with a workflow table.
Free resources
- Optimistic Offline Lock (Martin Fowler, P of EAA): the canonical description of version-checked writes for long user edits.
- RFC 9110, section 13.1.1 If-Match: how HTTP expresses "update only if unchanged" with ETags and 412 responses.
- Kubernetes API concepts: see the resource versions section, the same idea applied to every object in a cluster.
- Specification (Martin Fowler and Eric Evans): the pattern behind composable guards and queries.
- Kanban (development) (Wikipedia)): background on WIP limits and why teams use them.