Skip to main content

Task Management

Design a Trello-shaped tool: boards contain tasks, tasks move through statuses and get assigned to users. It reads like a straightforward CRUD app, so the prompt is really testing whether you notice the one rule that isn't CRUD: a task can't jump straight from "not started" to "done".

Requirements

Functional

  • A Board contains Tasks.
  • A task has a status - TODO, IN_PROGRESS, or DONE - and can be assigned to a User.
  • Status changes are constrained: TODO -> IN_PROGRESS -> DONE, and DONE -> TODO (reopening) is allowed, but TODO -> DONE directly is not.
  • A user can list every task assigned to them, filtered by status, across every board they belong to.

Non-functional

  • Assignment logic (who can a task be assigned to - anyone, or only board members) needs to change per board without touching Task itself.
  • Listing "my tasks by status" must not force a linear scan of every task in every board; it should be answerable straight from an index kept in sync as tasks change.

Design

Status transitions are the one place this domain has real rules, so they get pulled out of Task and into a TaskStatus enum that owns its own canTransitionTo() check - the same "is this jump legal" question the fee strategy in Parking Lot answers for pricing, just applied to a state enum here instead of a pluggable strategy, because the rule set (three statuses, two directions) is fixed rather than swappable.

moveTo(IN_PROGRESS)moveTo(DONE)moveTo(TODO)TODOIN_PROGRESSDONE
Click a state to see its legal transitions.

This is a cycle, not a one-way pipeline - DONE can move back to TODO (reopening a task), but nothing skips a step: TODO cannot jump straight to DONE, and IN_PROGRESS cannot jump back to TODO. Every one of those illegal jumps is exactly what canTransitionTo()'s switch rejects, and the diagram makes the shape of that rule visible at a glance instead of requiring a reader to trace the switch statement by hand.

ClientBoardTaskTaskStatusassignTask(task, user)1policy.canAssign(user)2assign(user)3moveTo(IN_PROGRESS)4canTransitionTo(IN_PROGRESS)5index.update(task)6
  1. 1Assignment always goes through the board - it owns the policy for who is eligible.
  2. 2The board checks its own assignment policy before touching the task at all.
  3. 3Only once the policy approves does the task record its assignee.
  4. 4A status change is requested directly on the task, since legality only depends on the current status.
  5. 5The task delegates the legality check to the enum value itself rather than hard-coding the transition table.
  6. 6Whichever field changed, the board refreshes that user’s per-status index so lookups stay O(1).

Board maintains the assignable-member policy and a TaskIndex per user that gets updated on every assignment or status change, so "my tasks in progress" is a lookup, not a scan.

Class diagram

«interface»AssignmentPolicy+ canAssign(u): boolBoard- tasks: List<Task>- policy: AssignmentPolicy- taskIndex: Map<User, Map<TaskStatus, Set<Task>>>+ assignTask(t, u)+ tasksFor(u, status): Set<Task>Task- title: string- status: TaskStatus- assignee: User+ moveTo(status)+ assign(u)OpenAssignmentPolicy+ canAssign(u): boolUser- id: string- name: string
implementsuses
Task delegates transition legality to TaskStatus; Board maintains an assignment policy and a per-user index.

Code

import java.util.*;
 
enum TaskStatus {
TODO, IN_PROGRESS, DONE;
 
boolean canTransitionTo(TaskStatus next) {
return switch (this) {
case TODO -> next == IN_PROGRESS;
case IN_PROGRESS -> next == DONE;
case DONE -> next == TODO;
};
}
}
 
class User {
final String id;
final String name;
 
User(String id, String name) {
this.id = id;
this.name = name;
}
}
 
class Task {
final String title;
private TaskStatus status = TaskStatus.TODO;
private User assignee;
 
Task(String title) {
this.title = title;
}
 
void moveTo(TaskStatus next) {
if (!status.canTransitionTo(next)) {
throw new IllegalStateException("Cannot move from " + status + " to " + next);
}
status = next;
}
 
void assign(User user) {
this.assignee = user;
}
 
TaskStatus getStatus() { return status; }
User getAssignee() { return assignee; }
}
 
interface AssignmentPolicy {
boolean canAssign(User user);
}
 
class OpenAssignmentPolicy implements AssignmentPolicy {
public boolean canAssign(User user) { return true; }
}
 
class Board {
private final List<Task> tasks = new ArrayList<>();
private final AssignmentPolicy policy;
private final Map<User, Map<TaskStatus, Set<Task>>> taskIndex = new HashMap<>();
 
Board(AssignmentPolicy policy) {
this.policy = policy;
}
 
void addTask(Task task) {
tasks.add(task);
}
 
void assignTask(Task task, User user) {
if (!policy.canAssign(user)) {
throw new IllegalStateException(user.name + " is not allowed on this board");
}
task.assign(user);
reindex(task, user);
}
 
private void reindex(Task task, User user) {
taskIndex.computeIfAbsent(user, u -> new HashMap<>())
.computeIfAbsent(task.getStatus(), s -> new HashSet<>())
.add(task);
}
 
Set<Task> tasksFor(User user, TaskStatus status) {
return taskIndex.getOrDefault(user, Map.of()).getOrDefault(status, Set.of());
}
}

Design decisions

  • TaskStatus.canTransitionTo() lives on the enum, not in Task.setStatus(). Keeping the legality check next to the values it governs means adding a fourth status (BLOCKED, say) means updating one enum's transition table, not hunting through every method that ever calls setStatus.
  • Task.assign() asks the board, not the user, whether an assignment is allowed. The rule "who can this go to" is a property of the board (a personal to-do board allows anyone; a company board might restrict to members), not of the task or the user, so it's the board's AssignmentPolicy that gets consulted - swapping policies never touches Task.
  • Each user's task list is a maintained index (Map<Status, Set<Task>> per user), not a filter over every task on every read. The index is updated exactly where a task's assignee or status changes - two call sites - which is cheap enough to keep synchronous and avoids ever re-scanning a board to answer "what's assigned to me."
  • What's missing for a real system: cross-board task search needs a denormalized index outside any single Board (this design's per-user index is board-scoped and would need merging across boards), and concurrent status updates from two clients need optimistic locking on Task.version - both cut here to keep the transition-and-assignment logic the whole focus.

Common follow-ups

  • How do you add a BLOCKED status a task can enter from IN_PROGRESS and must leave before DONE? Add BLOCKED to the TaskStatus enum and extend its transition table (IN_PROGRESS -> BLOCKED, BLOCKED -> IN_PROGRESS) - Task.moveTo doesn't change since it only ever calls canTransitionTo.
  • How would you support cross-board "my tasks" search? Add an index that isn't board-scoped - a top-level Map<User, Map<TaskStatus, Set<Task>>> maintained the same way a board's per-board index is, updated from the same two call sites, since board-scoped indexes can't be merged cheaply per query.
  • What happens if two clients change the same task's status at the same time? Without extra work, the second write silently wins - a real fix adds a version field to Task and has moveTo check-and-increment it, rejecting a write based on stale data (optimistic locking).
  • How would you let a board restrict assignment to only members with a specific role? Write a new AssignmentPolicy implementation (RoleBasedAssignmentPolicy) and construct the board with it - Board.assignTask and Task.assign don't change at all, since they only ever call policy.canAssign(user).

Check yourself

Question 1 of 4

Why does canTransitionTo() live on the TaskStatus enum instead of inside Task.moveTo()?