Design a task management system (Trello-like)
Boards, lists, cards, state transitions and watchers.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Observer and event listeners — Telling interested objects something happened, without knowing who they are.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- Relationships and multiplicity — Association, aggregation, composition, and the numbers on the lines.
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.
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
add_cardcreates aCard, appends its id to the list, pushes on the heap.moveremoves the id from the source list, inserts in the target and records an event.set_statuscallscard.move_to, which checks the table and returns an event; the service notifies watchers.top_prioritypops 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_priorityhas 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
Cardand 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_priorityand 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
cardsand 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
- LLD Mastery · Google-tagged (Easy); state transitions, Observer
- Codemia · Codemia Medium
- GitHub · awesome-low-level-design, Easy
- workat.tech · workat.tech machine coding 'Design Trello' (SDE II)
- LeetCode Discuss · LeetCode 3408 Design Task Manager, Google-tagged (freq 21.0)
- raw.githubusercontent.com · Working React components; async cancellation
- Instamock · Tagged Google; CRUD, search