Engineering Atlas › Low-Level Design

Code a task tracker with assignees and statuses

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

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

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 : consumes

Key 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, v6

Design patterns used and why

Concurrency and edge cases

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

  1. People edit in slow dialogs, so holding a lock during an edit would block colleagues for minutes; optimistic versions hold nothing.
  2. Conflicts are rare but costly when silent, so the write must detect them and the UI must show them.
  3. Teams disagree on process, so the workflow is configuration, and the rules on it are small pluggable guards.
  4. 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.
  5. Boards are read far more than written, so indexes match the board's filter and sort exactly.

Extensibility

Leadership lens

Check yourself

  1. 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.

  2. 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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →