The 100 problems, solved / Applications and services / 091 · Design a task management system (Trello-like)

Design a task management system (Trello-like)

Boards, lists, cards, state transitions and watchers.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

This is a Trello-style board. The risk is not difficulty but sprawl, so keep the model small and the rules explicit.

  • A user creates a board with ordered lists such as To do, Doing, Done.
  • A user adds a card to a list, with a title, an assignee, a priority and a due date.
  • A user moves a card between lists and reorders within one.
  • A card follows workflow rules: for example a blocked card cannot move to Done.
  • A user watches a card and is notified of changes.
  • A user finds cards by assignee or status, and asks for the highest-priority open task.

Out of scope: attachments, permissions, real-time sync and persistence. Non-functional constraints: moves keep ordering consistent; a new workflow rule needs no change to Card; every change is recorded as history. Reported variant: LeetCode's Design Task Manager wants add, edit, remove and execute-top by priority, so the priority query should be efficient.

2. Core entities and relationships

  • Board: owns lists and members.
  • TaskList: a named, ordered sequence of card ids.
  • Card: title, assignee, priority, status, watchers, history.
  • CardStatus: OPEN, IN_PROGRESS, BLOCKED, DONE with a transition table.
  • Watcher: anything notified of a card event.
  • TaskService: the use cases, plus a heap for top-priority queries.
ServiceBoardObservers*1..*ordered ids**emitsTaskServiceboards: dictcards: dictheap: listadd_card(board, list, title, priority)move(card_id, to_list, index)set_status(card_id, status)top_priority()Boardid: strlists: dictmembers: setTaskListname: strcard_ids: listinsert(card_id, index)remove(card_id)Cardid: strtitle: strpriority: intstatus: CardStatusassignee: strwatchers: listhistory: listmove_to(status)CardEventcard_id: strkind: strdetail: strWatcheron_event(event)

A board composes its lists, so deleting a board deletes them. Lists hold card ids rather than owning cards: a card moves between lists without being recreated, which is why the multiplicity is exactly one list per card at a time, kept in card.list_name.

3. Interfaces and key methods

class Watcher(Protocol):
    def on_event(self, event: CardEvent) -> None: ...

class Card:
    def move_to(self, status: CardStatus) -> CardEvent:
        """Apply a legal status change; raise IllegalTransition otherwise."""
    def watch(self, watcher: Watcher) -> None: ...

class TaskList:
    def insert(self, card_id: str, index: int) -> None: ...
    def remove(self, card_id: str) -> None: ...

class TaskService:
    def add_card(self, board_id: str, list_name: str, title: str, priority: int = 0) -> Card: ...
    def move(self, card_id: str, to_list: str, index: int) -> None:
        """Remove from the old list and insert in the new one as a single step."""
    def set_priority(self, card_id: str, priority: int) -> None: ...
    def set_status(self, card_id: str, status: CardStatus) -> None: ...
    def top_priority(self) -> Optional[Card]:
        """Highest priority open card, ties to the newest id."""

Invariants: a card id appears in exactly one list; statuses only change along the table; every change appends one history event and notifies each watcher once.

4. Design patterns and the choices behind them

Status as a table

The state machine here has four states with no different behaviour, only different successors, so a dictionary of allowed moves beats four classes. Keep the status separate from the list: a list is where a card sits on the board and a status is the workflow, and boards legitimately differ on how they map. Conflating them is the shortcut interviewers ask you to undo.

Watchers as observers

Email, an activity feed and automation all react to card changes. Observers on the card keep the card free of notification code. A rejected alternative is a list of user ids on the card with mail logic in TaskService, which couples every change to one delivery channel.

Ids in lists, not objects

The multiplicity is what drives this: many lists, many cards, each card in one list. Lists holding ids keep a single owner for cards and make a move two list edits. Noun-picking from the requirements gives you exactly the classes above; resist a separate Column class that adds nothing.

Lazy heap for the priority query

Priority edits would need a heap update in the middle. Pushing a new entry and skipping stale ones when they surface gives O(log n) edits with almost no code, and removal is the same trick.

5. Core flow and code

  1. add_card creates a Card, appends its id to the list, pushes on the heap.
  2. move removes the id from the source list, inserts in the target and records an event.
  3. set_status calls card.move_to, which checks the table and returns an event; the service notifies watchers.
  4. top_priority pops stale heap entries until the top matches a live open card.
from __future__ import annotations
import heapq, itertools
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Protocol


class IllegalTransition(Exception): ...


class CardStatus(Enum):
    OPEN = "open"; IN_PROGRESS = "in_progress"; BLOCKED = "blocked"; DONE = "done"


ALLOWED = {
    CardStatus.OPEN: {CardStatus.IN_PROGRESS, CardStatus.BLOCKED},
    CardStatus.IN_PROGRESS: {CardStatus.BLOCKED, CardStatus.DONE, CardStatus.OPEN},
    CardStatus.BLOCKED: {CardStatus.OPEN, CardStatus.IN_PROGRESS},
    CardStatus.DONE: {CardStatus.OPEN},
}


@dataclass(frozen=True)
class CardEvent:
    card_id: str
    kind: str
    detail: str


class Watcher(Protocol):
    def on_event(self, event: CardEvent) -> None: ...


@dataclass
class Card:
    id: str
    title: str
    priority: int
    board_id: str
    list_name: str
    assignee: str = ""
    status: CardStatus = CardStatus.OPEN
    watchers: list[Watcher] = field(default_factory=list)
    history: list[CardEvent] = field(default_factory=list)

    def watch(self, watcher: Watcher) -> None:
        self.watchers.append(watcher)

    def move_to(self, status: CardStatus) -> CardEvent:
        if status not in ALLOWED[self.status]:
            raise IllegalTransition(f"{self.status.value} -> {status.value}")
        self.status = status
        return CardEvent(self.id, "status", status.value)

    def publish(self, event: CardEvent) -> None:
        self.history.append(event)
        for w in self.watchers:
            w.on_event(event)


@dataclass
class TaskList:
    name: str
    card_ids: list[str] = field(default_factory=list)
    def insert(self, card_id: str, index: int) -> None:
        self.card_ids.insert(max(0, min(index, len(self.card_ids))), card_id)
    def remove(self, card_id: str) -> None:
        self.card_ids.remove(card_id)


@dataclass
class Board:
    id: str
    lists: dict[str, TaskList] = field(default_factory=dict)
    members: set[str] = field(default_factory=set)
    def add_list(self, name: str) -> TaskList:
        return self.lists.setdefault(name, TaskList(name))


class TaskService:
    def __init__(self) -> None:
        self.boards: dict[str, Board] = {}
        self.cards: dict[str, Card] = {}
        self.heap: list[tuple[int, int, str]] = []
        self._ids = itertools.count(1)

    def create_board(self, board_id: str, list_names: list[str]) -> Board:
        board = Board(board_id)
        for name in list_names:
            board.add_list(name)
        self.boards[board_id] = board
        return board

    def _push(self, card: Card) -> None:
        heapq.heappush(self.heap, (-card.priority, -int(card.id[1:]), card.id))

    def add_card(self, board_id: str, list_name: str, title: str, priority: int = 0) -> Card:
        card = Card(f"c{next(self._ids)}", title, priority, board_id, list_name)
        self.cards[card.id] = card
        lst = self.boards[board_id].lists[list_name]
        lst.insert(card.id, len(lst.card_ids))
        self._push(card)
        return card

    def move(self, card_id: str, to_list: str, index: int) -> None:
        card = self.cards[card_id]
        board = self.boards[card.board_id]
        target = board.lists[to_list]                  # fails before any change if unknown
        board.lists[card.list_name].remove(card_id)
        target.insert(card_id, index)
        card.list_name = to_list
        card.publish(CardEvent(card_id, "moved", to_list))

    def set_priority(self, card_id: str, priority: int) -> None:
        card = self.cards[card_id]
        card.priority = priority
        self._push(card)                               # the old entry goes stale
        card.publish(CardEvent(card_id, "priority", str(priority)))

    def set_status(self, card_id: str, status: CardStatus) -> None:
        card = self.cards[card_id]
        card.publish(card.move_to(status))

    def top_priority(self) -> Optional[Card]:
        while self.heap:
            pri, _, card_id = self.heap[0]
            card = self.cards.get(card_id)
            if card and card.priority == -pri and card.status is not CardStatus.DONE:
                return card
            heapq.heappop(self.heap)
        return None

6. Extensions, concurrency and what interviewers probe

  • Reopening a done card: top_priority has already discarded its heap entry. Push again whenever a card leaves DONE; a good follow-up to spot unprompted.
  • Automation ("when moved to Done, assign to the reviewer") is an observer that calls back into the service, so guard against loops.
  • Stale edits: two users edit one card. Add a version to Card and reject a write with an old version; that is optimistic concurrency, a better fit than locks for human-speed edits.
  • Move atomicity: the target list is looked up before the source is touched, so an unknown list changes nothing.
  • Test first: moving keeps the id in exactly one list; OPEN to DONE raises; a raised priority wins top_priority and the stale entry is skipped; a watcher hears one event per change.
  • Finding cards: a filter by assignee or status is a scan of cards, which is fine for a board of hundreds. An index from assignee to card ids is the next step; it must be updated on every reassignment, which is the cost to say out loud before adding it.
  • Removing a card deletes it from cards and its list; the heap entry goes stale and is skipped, so removal needs no heap surgery.
  • Common mistake: a God class called Board that holds everything. Keep behaviour with the data it changes.

02Where this question was reported