Task Management
State machine for task lifecycle, observers for notifications, commands for undo. Three patterns that handle the three hardest parts of any project tracker.
Key Abstractions
Orchestrator that manages tasks, users, sprints, and coordinates notifications
Entity with state machine transitions: TODO to IN_PROGRESS to DONE
Can be assignee or reporter, receives notifications on subscribed tasks
Time-boxed container grouping tasks for a development iteration
Interface for receiving notifications when task state changes
Class Diagram
The Key Insight
A task management system is fundamentally a state machine with an audience. Tasks move through a defined lifecycle (TODO, IN_PROGRESS, DONE), and every transition matters to someone: the assignee, the reporter, the team lead, maybe a CI pipeline. You need the transitions to be strict and the notifications to be decoupled.
The state machine prevents chaos. Without explicit transition rules, someone will mark a task DONE that was never started, and your sprint metrics become meaningless. By encoding valid transitions in a map, the system enforces workflow discipline without relying on humans to follow process.
The Observer pattern handles the "audience" problem. When a task changes, you do not want the Task class importing an email library, a Slack SDK, and a webhook client. Observers let you bolt on notification channels without the task ever knowing they exist. The Command pattern rounds things out by making every action reversible. Accidentally moved a task to DONE? Undo it and the previous state is restored cleanly.
Requirements
Functional
- Create tasks with title, reporter, and initial TODO state
- Assign tasks to users
- Transition tasks through states: TODO -> IN_PROGRESS -> DONE (and reopen: DONE -> TODO)
- Group tasks into sprints
- Notify observers on state changes and assignments
- Undo the most recent action (transition or assignment)
Non-Functional
- Invalid state transitions must be rejected with clear error messages
- Adding new notification channels should not require modifying the Task class
- Undo history should be bounded in production to prevent unbounded memory growth
Design Decisions
Why a transition map instead of open-ended state changes?
Free-form transitions let tasks jump from TODO to DONE. That breaks workflow tracking, sprint burndown charts, and any process the team relies on. A transition map is a whitelist: if the move is not explicitly allowed, it does not happen. You can always add transitions later. Removing them from a permissive system is much harder.
Why Observer over direct notification calls?
If Task directly calls emailService.send() and slackService.post(), you have coupled your domain entity to infrastructure. Testing requires mocking two services. Adding a third channel means editing Task. With Observer, Task just says "something happened" and each observer decides what to do with that information. You can add, remove, or replace observers at runtime.
Why Command for undo instead of storing state snapshots?
Snapshots work for small objects. But tasks can accumulate comments, attachments, and history. Snapshotting all of that on every change is expensive. Commands only store the delta: what changed and what the previous value was. Undo is replaying that delta in reverse. Much lighter.
Why is Sprint just a container of task IDs?
A Sprint does not own tasks. Multiple sprints might reference the same task (moved from one sprint to the next). Storing IDs instead of task references means the Sprint is loosely coupled to the task lifecycle. Delete a sprint, the tasks survive.
Interview Follow-ups
- "How would you add subtasks?" Give Task a
parentIdfield and asubtaskslist. A parent task cannot move to DONE until all subtasks are DONE. Recursive state validation. - "How do you handle concurrent edits?" Optimistic locking with a version field on Task. If two people try to transition the same task, the second one gets a conflict error and must reload.
- "How would you add priority and due dates?" Add fields to Task and a comparator-based sorting strategy for the board view. Priority changes are just another Command type for undo support.
- "What about audit logging?" Add an AuditObserver that writes every event to an append-only log. Since it implements TaskObserver, the task itself never knows about auditing.
45-Minute LLD Playbook
Each phase is what the interviewer expects you to do and say. Concrete steps, not topic hints. Diagrams are what you would sketch on the board.
- 15 min
Clarify scope and lock the three concerns
GoalPin down the task lifecycle, the notification surface, and the undo contract as three orthogonal concerns. End with the diagram-or-code question.
Do & Say- ASK·1Open with: A task has a title, a reporter, an assignee, and a state. In scope: TODO/IN_PROGRESS/DONE lifecycle with reopen, assignment, notifications on state change, single-step undo.
- SAY·2Park explicitly: Subtasks, dependencies, priorities, due dates, attachments are out of v1.
- SAY·3Lock the lifecycle: TODO -> IN_PROGRESS, IN_PROGRESS -> DONE, DONE -> TODO for reopen, IN_PROGRESS -> TODO for moving back. No TODO -> DONE shortcut. Transition map is a dict, single source of truth.
- SAY·4Lock notifications: Observer on Task. Task fires events, observers decide. ConsoleNotifier in v1, EmailNotifier and SlackNotifier later. Task never imports an email client.
- SAY·5Lock undo: Single undo via Command history on TaskBoard. TransitionCommand captures previousState, AssignCommand captures previousAssignee. No multi-step undo, no redo.
- SAY·6Pin the Sprint relationship: Sprint is a container of task IDs, not references. A task can live in multiple sprints. Delete a sprint, tasks survive.
- ASK·7Ask: Sketch Task, TaskBoard, Command, TaskObserver first, or jump to code?. The three-pattern overlay is the core of the design.
Interviewer is grading: You scope the undo contract narrowly (single-step, no redo). You separate Sprint as a container of IDs not a parent. You ask the diagram-vs-code question to plan time.
- 25-10 min
Sketch the transition map and Command structure
GoalWrite the VALID_TRANSITIONS dict, the Command interface, and the Task signature with observer support. Either draw the class diagram or verbalize.
Do & Say- WRITE·1Write the transition map: TODO -> {IN_PROGRESS}, IN_PROGRESS -> {DONE, TODO}, DONE -> {TODO}. DONE -> TODO is reopen. IN_PROGRESS -> TODO is when an assignee gets pulled off.
- SAY·2Justify the missing edge: No TODO -> DONE because skipping IN_PROGRESS breaks sprint metrics.
- WRITE·3Write the Command interface: execute() and undo() return void. Each concrete command captures pre-state in execute, restores it in undo. TransitionCommand and AssignCommand are the two concretes.
- WRITE·4Write the Task contract: transition(new_state) validates against the map then fires observers. assign(user) sets assignee and fires observers. add_observer appends to the internal list.
- WRITE·5Write the TaskBoard contract: createTask. moveTask wraps in TransitionCommand and pushes history. assignTask wraps in AssignCommand and pushes history. undo pops and reverses. createSprint, getTask.
- SAY·6If a diagram was requested, draw TaskBoard at the top with arrows to Task, User, Sprint, Command stack. Task points to TaskState, TaskObserver (with ConsoleNotifier), and User. TransitionCommand and AssignCommand implement Command.
Interviewer is grading: You write the transition map literally on the board. You name TransitionCommand.previousState as the field that makes undo possible. You separate moveTask from transition: TaskBoard creates the Command, Task does the actual state change.
- 325 min
Code in this sequence (bottom-up)
GoalType the code in pythonCode order. Talk through the previous-state capture and the observer fan-out as you write.
Do & Say- SAY·1Start with TaskState enum (TODO, IN_PROGRESS, DONE) and VALID_TRANSITIONS dict at module level. Immutable, shared, doubles as documentation. (~1 min)
- SAY·2Code User: id, name, email, with __repr__ for debug. Plain data, no behavior. (~1 min)
- SAY·3Code TaskObserver abstract with on_task_updated(task, event), and ConsoleNotifier that prints Task {title} ({id[:8]}): {event}. (~2 min)
- SAY·4Code Task constructor: takes title and reporter, generates UUID, state=TODO, assignee=None, created_at=datetime.now(), _observers=[]. (~2 min)
- SAY·5Code Task methods: add_observer, notify_observers. transition(new_state) checks VALID_TRANSITIONS and raises on illegal, then sets state and notifies. assign(user) sets assignee and notifies. (~3 min)
- SAY·6Defend the order: transition validates then notifies. Observers don't get called for rejected transitions, only successful ones. (~1 min)
- SAY·7Code the Command abstract base: execute() and undo(), both abstract. (~1 min)
- SAY·8Code TransitionCommand: constructor takes task and new_state. execute captures self._previous_state = self._task.state THEN calls self._task.transition. (~2 min)
- SAY·9Code TransitionCommand.undo: directly sets self._task.state = self._previous_state and notifies. Undo bypasses transition() so DONE->IN_PROGRESS works even though the forward path doesn't exist. (~2 min)
- SAY·10Code AssignCommand: constructor takes task and user. execute captures _previous_assignee then calls task.assign(user). undo restores the assignee directly and notifies. (~2 min)
- SAY·11Code Sprint: name, task_ids list, add_task with idempotency check, remove_task. IDs, not references; Sprint deletion does not cascade. (~2 min)
- SAY·12Code TaskBoard: tasks dict, users dict, sprints list, history list for Command stack, _notifier ConsoleNotifier. add_user. create_task adds the notifier as observer and stores. (~2 min)
- SAY·13Code TaskBoard.move_task and assign_task: each creates the right Command, executes, appends to history. undo pops the last command and calls its undo(). create_sprint and get_task are trivial. (~3 min)
- SAY·14Walk through: create task, assign Bob (history=1). Move TODO->IN_PROGRESS (history=2), move IN_PROGRESS->DONE (history=3). Undo restores IN_PROGRESS. TODO->DONE on another task raises because the edge isn't in the map. (~2 min)
Interviewer is grading: TransitionCommand.execute captures previous_state BEFORE calling transition, not after. undo restores state directly (bypassing the transition map) since the reverse may not be a legal forward transition. The observer list is on Task, not on TaskBoard. Sprint stores task_ids, not Task references.
- 45 min
Trade-offs, extensions, and wrap-up
GoalDefend Command over snapshots and Observer over direct calls, volunteer subtasks and audit logging as next steps, summarize in one sentence.
Do & Say- SAY·1Trade-off one, Command over Task snapshots: Snapshotting Task on every change wastes memory once tasks accumulate comments and history. Commands store the delta and the prior value. Small per command, fast undo, bounded history.
- SAY·2Trade-off two, Observer over direct calls: Task.transition calling email.send and slack.post couples Task to infrastructure. Observer makes Task fire one event. ConsoleNotifier in tests, EmailNotifier and SlackNotifier in prod, no edits to Task.
- SAY·3Volunteer subtasks: Task gets parent_id and a subtasks list. transition to DONE on a parent checks every subtask.state == DONE. Reopening a parent does not cascade to subtasks; the rule is one-way.
- WATCH·4Be ready for audit: AuditObserver appends to an append-only log keyed by task_id and timestamp. Task never knows it exists. Compliance gets full history without coupling.
- SAY·5Close with one sentence: State machine on Task for transition discipline, Observer on Task for notifications, Command on TaskBoard for undo. Three patterns, three concerns, zero coupling.
Interviewer is grading: You defend Command by naming the memory cost of snapshots concretely. You frame Observer as making Task testable without mocking infrastructure. You volunteer subtasks with the parent-DONE rule unprompted.
Code Implementation
from abc import ABC, abstractmethod
from enum import Enum
from datetime import datetime
import uuid
class TaskState(Enum):
TODO = "TODO"
IN_PROGRESS = "IN_PROGRESS"
DONE = "DONE"
# Valid transitions: which states can move to which
VALID_TRANSITIONS: dict[TaskState, set[TaskState]] = {
TaskState.TODO: {TaskState.IN_PROGRESS},
TaskState.IN_PROGRESS: {TaskState.DONE, TaskState.TODO},
TaskState.DONE: {TaskState.TODO}, # reopen
}
class User:
def __init__(self, user_id: str, name: str, email: str = ""):
self.id = user_id
self.name = name
self.email = email
def __repr__(self) -> str:
return f"User({self.name})"
class TaskObserver(ABC):
"""Observer interface for task change notifications."""
@abstractmethod
def on_task_updated(self, task: "Task", event: str) -> None: ...
class ConsoleNotifier(TaskObserver):
def on_task_updated(self, task: "Task", event: str) -> None:
print(f" [Notification] Task '{task.title}' ({task.id[:8]}): {event}")
class Task:
"""Entity with state machine and observer support."""
def __init__(self, title: str, reporter: User):
self.id = str(uuid.uuid4())
self.title = title
self.state = TaskState.TODO
self.reporter = reporter
self.assignee: User | None = None
self.created_at = datetime.now()
self._observers: list[TaskObserver] = []
def add_observer(self, observer: TaskObserver) -> None:
self._observers.append(observer)
def notify_observers(self, event: str) -> None:
for obs in self._observers:
obs.on_task_updated(self, event)
def transition(self, new_state: TaskState) -> None:
if new_state not in VALID_TRANSITIONS.get(self.state, set()):
raise ValueError(
f"Invalid transition: {self.state.value} -> {new_state.value}"
)
old_state = self.state
self.state = new_state
self.notify_observers(f"State changed: {old_state.value} -> {new_state.value}")
def assign(self, user: User) -> None:
self.assignee = user
self.notify_observers(f"Assigned to {user.name}")
def __repr__(self) -> str:
assignee_name = self.assignee.name if self.assignee else "Unassigned"
return f"Task({self.title}, {self.state.value}, {assignee_name})"
class Command(ABC):
"""Command interface for undoable operations."""
@abstractmethod
def execute(self) -> None: ...
@abstractmethod
def undo(self) -> None: ...
class TransitionCommand(Command):
def __init__(self, task: Task, new_state: TaskState):
self._task = task
self._new_state = new_state
self._previous_state: TaskState | None = None
def execute(self) -> None:
self._previous_state = self._task.state
self._task.transition(self._new_state)
def undo(self) -> None:
if self._previous_state is None:
raise ValueError("Cannot undo: command was never executed")
old = self._task.state
self._task.state = self._previous_state
self._task.notify_observers(
f"Undo: reverted {old.value} -> {self._previous_state.value}"
)
class AssignCommand(Command):
def __init__(self, task: Task, user: User):
self._task = task
self._user = user
self._previous_assignee: User | None = None
def execute(self) -> None:
self._previous_assignee = self._task.assignee
self._task.assign(self._user)
def undo(self) -> None:
old_name = self._task.assignee.name if self._task.assignee else "None"
self._task.assignee = self._previous_assignee
new_name = self._previous_assignee.name if self._previous_assignee else "None"
self._task.notify_observers(f"Undo assign: {old_name} -> {new_name}")
class Sprint:
"""Time-boxed container for tasks."""
def __init__(self, name: str):
self.name = name
self.task_ids: list[str] = []
def add_task(self, task_id: str) -> None:
if task_id not in self.task_ids:
self.task_ids.append(task_id)
def remove_task(self, task_id: str) -> None:
self.task_ids.remove(task_id)
def __repr__(self) -> str:
return f"Sprint({self.name}, {len(self.task_ids)} tasks)"
class TaskBoard:
"""Orchestrates tasks, users, sprints, and undo history."""
def __init__(self):
self._tasks: dict[str, Task] = {}
self._users: dict[str, User] = {}
self._sprints: list[Sprint] = []
self._history: list[Command] = []
self._notifier = ConsoleNotifier()
def add_user(self, user: User) -> None:
self._users[user.id] = user
def create_task(self, title: str, reporter: User) -> Task:
task = Task(title, reporter)
task.add_observer(self._notifier)
self._tasks[task.id] = task
print(f" Created task: '{title}' (id: {task.id[:8]})")
return task
def move_task(self, task_id: str, new_state: TaskState) -> None:
task = self._tasks[task_id]
cmd = TransitionCommand(task, new_state)
cmd.execute()
self._history.append(cmd)
def assign_task(self, task_id: str, user: User) -> None:
task = self._tasks[task_id]
cmd = AssignCommand(task, user)
cmd.execute()
self._history.append(cmd)
def undo(self) -> None:
if not self._history:
print(" Nothing to undo.")
return
cmd = self._history.pop()
cmd.undo()
def create_sprint(self, name: str) -> Sprint:
sprint = Sprint(name)
self._sprints.append(sprint)
return sprint
def get_task(self, task_id: str) -> Task:
return self._tasks[task_id]
if __name__ == "__main__":
board = TaskBoard()
alice = User("u1", "Alice", "alice@example.com")
bob = User("u2", "Bob", "bob@example.com")
board.add_user(alice)
board.add_user(bob)
# Create tasks
print("--- Creating Tasks ---")
t1 = board.create_task("Implement login API", alice)
t2 = board.create_task("Write unit tests", alice)
t3 = board.create_task("Deploy to staging", bob)
# Assign and transition
print("\n--- Assign and Move ---")
board.assign_task(t1.id, bob)
board.move_task(t1.id, TaskState.IN_PROGRESS)
board.move_task(t1.id, TaskState.DONE)
# Sprint management
print("\n--- Sprint ---")
sprint = board.create_sprint("Sprint 1")
sprint.add_task(t1.id)
sprint.add_task(t2.id)
print(f" {sprint}")
# Undo the last transition
print("\n--- Undo ---")
board.undo()
print(f" Task state after undo: {board.get_task(t1.id).state.value}")
# Invalid transition
print("\n--- Invalid Transition ---")
try:
board.move_task(t2.id, TaskState.DONE) # Can't skip IN_PROGRESS
except ValueError as e:
print(f" Caught expected error: {e}")
# Move t2 properly
print("\n--- Valid Flow ---")
board.move_task(t2.id, TaskState.IN_PROGRESS)
board.move_task(t2.id, TaskState.DONE)
print(f"\n Final state t1: {board.get_task(t1.id)}")
print(f" Final state t2: {board.get_task(t2.id)}")
print("All scenarios completed.")Interview Grading by Level
What an interviewer at each level expects to see in your answer. Use this to calibrate, not to perform.
Junior Engineer (L3)
Names Observer, State, Command by name and writes a Task with transitions, but the transition map and undo details stay vague.
- Names Observer for notifications and State for the task lifecycle correctly.
- Writes Task as a class with id, title, state, assignee fields.
- Recognizes TODO -> IN_PROGRESS -> DONE as the basic flow when prompted.
- Adds a Command interface when asked, with execute and undo.
- Creates a Sprint as a list of tasks.
- Uses an if/else chain over state strings instead of a transition map dict.
- Forgets to capture previous_state in TransitionCommand before executing, so undo has nothing to restore to.
- Has Task directly call print or some notification function instead of using observers.
- Makes Sprint own Task objects, so deleting a sprint loses tasks.
- Allows TODO -> DONE directly because there's no check.
Mid-Level Engineer (L4)
Drives the design end-to-end with the transition map, captured pre-state in commands, and an observer list on Task.
- Writes VALID_TRANSITIONS as a dict literal and validates every transition call against it.
- Implements TransitionCommand with previous_state captured in execute before delegating to transition.
- Implements undo by restoring state directly, not by attempting the reverse transition.
- Puts the observer list on Task and fans out from transition and assign.
- Implements AssignCommand symmetrically with previous_assignee.
- Stores Sprint as a list of task IDs, not Task references.
- Does not volunteer subtasks with the parent-DONE-requires-all-subtasks-DONE rule unless prompted.
- Misses AuditObserver as the natural compliance extension via the existing observer hook.
- Treats the history list as unbounded with no plan for trimming.
Senior Engineer (L5+)
Volunteers subtasks, audit logging, and concurrent-edit handling before being asked, names the bounded-history invariant, and frames each pattern as a defense against a specific failure.
- Volunteers subtasks with the recursive parent-DONE rule unprompted.
- Proposes AuditObserver as a TaskObserver that writes to an append-only log, with the observation that Task never knows about auditing.
- Raises optimistic locking on Task for concurrent edits: a version field, transition rejects if stale, client reloads. Names this before being asked.
- Names the bounded-history invariant: history is capped at N (e.g., 50) per board, oldest commands drop. Without this, long-running boards grow memory unboundedly.
- Frames each pattern around what it prevents: State machine prevents phantom DONE transitions that break sprint metrics, Observer prevents Task from depending on email and slack libraries, Command prevents Task from owning history.
- Proposes priority and due-date as Command-wrapped fields too, so changing priority is undoable, not just state transitions.
- Closes with one sentence naming State machine on Task, Observer for notification fan-out, Command on TaskBoard for undo, and the bounded-history invariant, in under 20 seconds.
Common Mistakes
- ✗Allowing arbitrary state transitions. A task jumping from TODO directly to DONE skips important workflow steps.
- ✗Notifying inside the Task class. That couples Task to every notification channel you will ever support.
- ✗Not storing the previous state in commands. Without it, undo has no idea what to revert to.
- ✗Making Sprint responsible for task state. Sprint groups tasks by time. State transitions are the task's own business.
Key Points
- ✓Task state transitions are explicit: TODO -> IN_PROGRESS -> DONE, with DONE -> TODO for reopening.
- ✓Observer pattern decouples notification logic from task state changes. Add email, Slack, or webhooks without touching Task.
- ✓Command pattern wraps state changes so you can undo them. Undo is just reversing the last command.
- ✓Sprints are containers, not owners. A task can move between sprints without losing its history.