Low Level Design
Design a Task Management System
A complete low-level design walkthrough for a task management system — from requirements to State, Strategy, Builder, and Singleton patterns with thread-safe task lifecycle management in Java.
Problem Description#
Design a Task Management System that lets users create, update, assign, and track tasks across their full lifecycle — from to-do through in-progress to done.
Task management sits at the intersection of several classic LLD challenges:
- A task has many optional attributes (assignee, due date, priority, tags, comments) that make construction messy without a Builder
- The task lifecycle has strict transition rules — you can't complete a task that hasn't started — which is exactly what the State pattern is designed to encode
- Multiple users may update the same task concurrently, requiring thread-safety at the model level
- Sorting tasks by different criteria (priority, due date) should be swappable at runtime without rewriting filter logic
This problem is a natural fit for four patterns working together: Builder, State, Strategy, and Singleton.
Clarify Requirements#
Before designing anything, ask these questions in an interview:
Functional
- What attributes does a task need? (title, description, due date, priority, assignee, tags, comments?)
- What are the allowed lifecycle states and transitions? Can a completed task be reopened?
- Can tasks have subtasks? Is there a nesting depth limit?
- Are tasks grouped into Epics / projects?
- What search and filtering operations are needed — by status, by assignee, by keyword?
- What sorting orders should be supported?
Non-functional
- Do multiple threads create and update tasks concurrently?
- Is there a persistent store, or is in-memory sufficient for this problem?
- Does the system need to be extensible for new states or sort strategies?
Final Requirements#
After clarification, here's what we'll build:
- Four priority levels — LOW, MEDIUM, HIGH, CRITICAL — stored as an enum for natural ordering
- Three lifecycle states — TODO → IN_PROGRESS → DONE — with legal transitions enforced by the State pattern; a done task can be reopened back to TODO
- Task Builder for clean, readable construction with optional fields (description, due date, assignee, tags)
- Tasks support subtasks, comments, tags, and an activity log that records every mutation
- Epics group related tasks; each epic displays its full task tree
- TaskManagementSystem Singleton — thread-safe central registry with ConcurrentHashMap for users, tasks, and epics
- Search by keyword (title + description match) with pluggable sort strategy (due date or priority)
- Filter tasks by status or by assigned user
Core Entities#
| Entity | Responsibility |
|---|---|
| Task | Core entity — holds all task data; delegates state transitions to its TaskState; thread-safe mutation methods |
| Task.TaskBuilder | Fluent builder — constructs Task with required title and optional fields |
| Epic | Groups tasks; CopyOnWriteArrayList for thread-safe iteration |
| User | Immutable record — id, name, email |
| Comment | Immutable record — content, author, timestamp |
| Tag | Immutable label attached to a task |
| TaskActivityLog | Timestamped string appended on every task mutation |
| TaskState | Interface — startProgress, completeTask, reopenTask, getStatus |
| ToDoState | Allows startProgress; rejects complete and reopen |
| InProgressState | Allows complete and reopen; rejects startProgress |
| DoneState | Allows reopen; rejects start and complete |
| TaskSortStrategy | Interface — sort(List<Task>); enables swappable sorting |
| SortByDueDate | Sorts ascending by due date |
| SortByPriority | Sorts descending by priority enum ordinal |
| TaskManagementSystem | Singleton — ConcurrentHashMap registries; create/delete/search/filter operations |
Patterns Used#
1. Singleton — TaskManagementSystem#
There is exactly one task registry in the application. TaskManagementSystem uses a synchronized getInstance() method and ConcurrentHashMap for its three registries (users, tasks, epics), making all read and write operations safe under concurrent access.
2. Builder — Task.TaskBuilder#
Task has eight fields, most of them optional. Without a Builder the constructor would quickly become unreadable. TaskBuilder takes only the required title in its constructor and exposes fluent setters for everything else, producing an immutable-ish Task at .build(). This also satisfies Open/Closed — adding a new optional field (e.g. estimatedHours) means adding one TaskBuilder method, not changing every call site.
new Task.TaskBuilder("Auth System")
.description("Implement JWT")
.dueDate(LocalDate.now().plusDays(3))
.priority(TaskPriority.CRITICAL)
.createdBy(pm)
.build();
3. State — TaskState#
Task lifecycle transitions are strict: TODO → IN_PROGRESS → DONE → TODO. Hard-coding this with if/else on a status enum means every new state adds branches across the codebase. The State pattern encapsulates each state's allowed transitions in its own class:
TODO → startProgress() → IN_PROGRESS
IN_PROGRESS → completeTask() → DONE
IN_PROGRESS → reopenTask() → TODO
DONE → reopenTask() → TODO
Illegal transitions (e.g. completing a TODO task) print a clear message and leave the state unchanged — no exceptions needed.
4. Strategy — TaskSortStrategy#
searchTasks() returns results sorted by whichever TaskSortStrategy the caller passes. SortByDueDate and SortByPriority are the two implementations; the system doesn't know which is in use. Adding SortByAssignee means creating one new class — no changes to TaskManagementSystem.
Code#
Enums — TaskPriority and TaskStatus#
public enum TaskPriority {
LOW, MEDIUM, HIGH, CRITICAL
}public enum TaskStatus {
TODO, IN_PROGRESS, DONE
}Models#
User, Tag, Comment, and TaskActivityLog are simple immutable records. Task is the rich entity — it owns its state, subtasks, comments, tags, and activity log.
import java.util.UUID;
public class User {
private final String userId;
private final String name;
private final String email;
public User(String name, String email) {
this.userId = UUID.randomUUID().toString();
this.name = name;
this.email = email;
}
public String getUserId() { return userId; }
public String getName() { return name; }
public String getEmail() { return email; }
}import java.util.UUID;
public class Tag {
private final String id;
private final String name;
public Tag(String name) {
this.id = UUID.randomUUID().toString();
this.name = name;
}
public String getName() { return name; }
}import java.time.LocalDateTime;
import java.util.UUID;
public class Comment {
private final String id;
private final String content;
private final User author;
private final LocalDateTime timestamp;
public Comment(String content, User author) {
this.id = UUID.randomUUID().toString();
this.content = content;
this.author = author;
this.timestamp = LocalDateTime.now();
}
public String getContent() { return content; }
public User getAuthor() { return author; }
public LocalDateTime getTimestamp() { return timestamp; }
}import java.time.LocalDateTime;
public class TaskActivityLog {
private final LocalDateTime timestamp;
private final String description;
public TaskActivityLog(String description) {
this.timestamp = LocalDateTime.now();
this.description = description;
}
@Override
public String toString() {
return "[" + timestamp + "] " + description;
}
}import java.time.LocalDate;
import java.util.*;
public class Task {
private final String taskId;
private String title;
private String description;
private TaskPriority priority;
private TaskState currentState;
private LocalDate dueDate;
private final User createdBy;
private User assignee;
private Set<Tag> tags;
private List<Comment> comments;
private List<TaskActivityLog> logs;
private final List<Task> subTasks;
private Task(TaskBuilder builder) {
this.taskId = builder.taskId;
this.title = builder.title;
this.description = builder.description;
this.priority = builder.priority;
this.dueDate = builder.dueDate;
this.createdBy = builder.createdBy;
this.assignee = builder.assignee;
this.tags = builder.tags != null ? builder.tags : new HashSet<>();
this.currentState = new ToDoState();
this.comments = new ArrayList<>();
this.subTasks = new ArrayList<>();
this.logs = new ArrayList<>();
addLog("Task created: " + title);
}
public String getTaskId() { return taskId; }
public String getTitle() { return title; }
public String getDescription() { return description; }
public TaskPriority getPriority() { return priority; }
public TaskState getCurrentState() { return currentState; }
public LocalDate getDueDate() { return dueDate; }
public User getAssignee() { return assignee; }
public synchronized void setAssignee(User user) {
this.assignee = user;
addLog("Assigned to " + user.getName());
}
public synchronized void updatePriority(TaskPriority priority) {
this.priority = priority;
addLog("Priority updated to " + priority.name());
}
public synchronized void addComment(Comment comment) {
comments.add(comment);
addLog("Comment added by " + comment.getAuthor().getName());
}
public synchronized void addSubtask(Task subtask) {
subTasks.add(subtask);
addLog("Subtask added: " + subtask.getTitle());
}
public synchronized void addTag(Tag tag) {
tags.add(tag);
addLog("Tag added: " + tag.getName());
}
/** Called by TaskState implementations to transition state. */
public void setState(TaskState state) {
this.currentState = state;
addLog("Status changed to: " + state.getStatus());
}
public void startProgress() { currentState.startProgress(this); }
public void completeTask() { currentState.completeTask(this); }
public void reopenTask() { currentState.reopenTask(this); }
public void addLog(String message) {
logs.add(new TaskActivityLog(message));
System.out.println("[LOG] " + message);
}
public void display(String indent) {
System.out.println(indent + "- " + title
+ " [" + currentState.getStatus() + ", " + priority
+ ", Due: " + dueDate + "]");
for (Task sub : subTasks) sub.display(indent + " ");
}
// ── Builder ──────────────────────────────────────────────────────
public static class TaskBuilder {
private final String taskId = UUID.randomUUID().toString();
private final String title;
private String description;
private TaskPriority priority;
private LocalDate dueDate;
private User createdBy;
private User assignee;
private Set<Tag> tags;
public TaskBuilder(String title) { this.title = title; }
public TaskBuilder description(String d) { this.description = d; return this; }
public TaskBuilder priority(TaskPriority p) { this.priority = p; return this; }
public TaskBuilder dueDate(LocalDate d) { this.dueDate = d; return this; }
public TaskBuilder createdBy(User u) { this.createdBy = u; return this; }
public TaskBuilder assignee(User u) { this.assignee = u; return this; }
public TaskBuilder tags(Set<Tag> t) { this.tags = t; return this; }
public Task build() { return new Task(this); }
}
}import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
public class Epic {
private final String id;
private final String name;
private final List<Task> tasks;
public Epic(String name) {
this.id = UUID.randomUUID().toString();
this.name = name;
this.tasks = new CopyOnWriteArrayList<>();
}
public String getId() { return id; }
public String getName() { return name; }
public void addTask(Task task) { tasks.add(task); }
public List<Task> getTasks() { return new ArrayList<>(tasks); }
public void display() {
System.out.println("--- Epic: " + name + " ---");
for (Task t : tasks) t.display("");
System.out.println("-----------------------------");
}
}State Pattern — Task Lifecycle#
TaskState encodes each lifecycle stage; only valid transitions are allowed.
public interface TaskState {
void startProgress(Task task);
void completeTask(Task task);
void reopenTask(Task task);
TaskStatus getStatus();
}public class ToDoState implements TaskState {
@Override public void startProgress(Task task) { task.setState(new InProgressState()); }
@Override public void completeTask(Task task) { System.out.println("Cannot complete — task hasn't started."); }
@Override public void reopenTask(Task task) { System.out.println("Task is already in TODO."); }
@Override public TaskStatus getStatus() { return TaskStatus.TODO; }
}public class InProgressState implements TaskState {
@Override public void startProgress(Task task) { System.out.println("Task is already in progress."); }
@Override public void completeTask(Task task) { task.setState(new DoneState()); }
@Override public void reopenTask(Task task) { task.setState(new ToDoState()); }
@Override public TaskStatus getStatus() { return TaskStatus.IN_PROGRESS; }
}public class DoneState implements TaskState {
@Override public void startProgress(Task task) { System.out.println("Reopen the task before starting again."); }
@Override public void completeTask(Task task) { System.out.println("Task is already done."); }
@Override public void reopenTask(Task task) { task.setState(new ToDoState()); }
@Override public TaskStatus getStatus() { return TaskStatus.DONE; }
}Strategy Pattern — Sorting#
import java.util.List;
public interface TaskSortStrategy {
void sort(List<Task> tasks);
}import java.util.Comparator;
import java.util.List;
public class SortByDueDate implements TaskSortStrategy {
@Override
public void sort(List<Task> tasks) {
tasks.sort(Comparator.comparing(Task::getDueDate));
}
}import java.util.Comparator;
import java.util.List;
public class SortByPriority implements TaskSortStrategy {
@Override
public void sort(List<Task> tasks) {
tasks.sort(Comparator.comparing(Task::getPriority).reversed());
}
}Singleton — TaskManagementSystem#
Central coordinator with thread-safe registries and all CRUD / search / filter operations.
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
public class TaskManagementSystem {
private static TaskManagementSystem instance;
private final Map<String, User> users;
private final Map<String, Epic> epicList;
private final Map<String, Task> tasks;
private TaskManagementSystem() {
users = new ConcurrentHashMap<>();
epicList = new ConcurrentHashMap<>();
tasks = new ConcurrentHashMap<>();
}
public static synchronized TaskManagementSystem getInstance() {
if (instance == null) instance = new TaskManagementSystem();
return instance;
}
// ── User ──────────────────────────────────────────────────────────
public User createUser(String name, String email) {
User user = new User(name, email);
users.put(user.getUserId(), user);
return user;
}
// ── Epic ──────────────────────────────────────────────────────────
public Epic createEpic(String name) {
Epic epic = new Epic(name);
epicList.put(epic.getId(), epic);
return epic;
}
// ── Task ──────────────────────────────────────────────────────────
public Task createTask(String title, String description,
LocalDate dueDate, TaskPriority priority,
String createdByUserId) {
User creator = users.get(createdByUserId);
if (creator == null) throw new IllegalArgumentException("Unknown user: " + createdByUserId);
Task task = new Task.TaskBuilder(title)
.description(description)
.dueDate(dueDate)
.priority(priority)
.createdBy(creator)
.build();
tasks.put(task.getTaskId(), task);
return task;
}
public void deleteTask(String taskId) {
tasks.remove(taskId);
}
// ── Filtering ──────────────────────────────────────────────────────
public List<Task> listTasksByUser(String userId) {
User user = users.get(userId);
if (user == null) throw new IllegalArgumentException("Unknown user: " + userId);
return tasks.values().stream()
.filter(t -> user.equals(t.getAssignee()))
.collect(Collectors.toList());
}
public List<Task> listTasksByStatus(TaskStatus status) {
return tasks.values().stream()
.filter(t -> t.getCurrentState().getStatus() == status)
.collect(Collectors.toList());
}
// ── Search ────────────────────────────────────────────────────────
public List<Task> searchTasks(String keyword, TaskSortStrategy sortStrategy) {
if (keyword == null || keyword.isBlank()) return List.of();
String kw = keyword.toLowerCase();
List<Task> results = tasks.values().stream()
.filter(t -> {
String title = t.getTitle() != null ? t.getTitle().toLowerCase() : "";
String desc = t.getDescription() != null ? t.getDescription().toLowerCase() : "";
return title.contains(kw) || desc.contains(kw);
})
.collect(Collectors.toList());
sortStrategy.sort(results);
return results;
}
}
Demo#
import java.time.LocalDate;
import java.util.List;
public class TaskManagementDemo {
public static void main(String[] args) {
TaskManagementSystem system = TaskManagementSystem.getInstance();
// Users
User pm = system.createUser("Product Manager", "pm@example.com");
User alice = system.createUser("Alice", "alice@example.com");
User bob = system.createUser("Bob", "bob@example.com");
// Epics
Epic backendEpic = system.createEpic("Backend Revamp");
Epic uiEpic = system.createEpic("UI Improvements");
// Tasks
Task task1 = system.createTask("Auth System",
"Implement JWT-based authentication",
LocalDate.now().plusDays(3), TaskPriority.CRITICAL, pm.getUserId());
Task task2 = system.createTask("Dashboard",
"Create admin dashboard",
LocalDate.now().plusDays(5), TaskPriority.HIGH, pm.getUserId());
Task subtask1 = system.createTask("Design Dashboard UI",
"Create Figma designs",
LocalDate.now().plusDays(2), TaskPriority.MEDIUM, pm.getUserId());
Task subtask2 = system.createTask("API Integration",
"Connect dashboard to backend APIs",
LocalDate.now().plusDays(4), TaskPriority.HIGH, pm.getUserId());
task2.addSubtask(subtask1);
task2.addSubtask(subtask2);
// Tags, comments, assignments
task1.addTag(new Tag("backend"));
task1.addTag(new Tag("urgent"));
task1.setAssignee(bob);
task1.addComment(new Comment("Start ASAP", pm));
task1.addComment(new Comment("Working on it", bob));
// Epics
backendEpic.addTask(task1);
uiEpic.addTask(task2);
backendEpic.display();
uiEpic.display();
// State transitions
task1.startProgress(); // TODO → IN_PROGRESS
task1.completeTask(); // IN_PROGRESS → DONE
task1.startProgress(); // error — reopen first
task1.reopenTask(); // DONE → TODO
task1.startProgress(); // TODO → IN_PROGRESS
// Assignments
subtask1.setAssignee(alice);
subtask2.setAssignee(bob);
task2.setAssignee(alice);
// Filter by status
List<Task> inProgress = system.listTasksByStatus(TaskStatus.IN_PROGRESS);
System.out.println("In-progress: " + inProgress.size());
// Filter by user
List<Task> aliceTasks = system.listTasksByUser(alice.getUserId());
aliceTasks.forEach(t -> System.out.println("Alice: " + t.getTitle()));
// Search + sort
System.out.println("Search 'dashboard', sorted by due date:");
system.searchTasks("dashboard", new SortByDueDate())
.forEach(t -> System.out.println(" " + t.getTitle()));
System.out.println("Search 'dashboard', sorted by priority:");
system.searchTasks("dashboard", new SortByPriority())
.forEach(t -> System.out.println(" " + t.getTitle()));
// Delete
system.deleteTask(subtask2.getTaskId());
System.out.println("Deleted: " + subtask2.getTitle());
uiEpic.display();
}
}
Class Diagram#
Extendible — Follow Ups#
1. Add a BLOCKED state#
Insert BlockedState between IN_PROGRESS and DONE. A blocked task must be unblocked (unblock() → IN_PROGRESS) before it can be completed. Adding this is one new class and one new transition method on the interface — no existing states change.
2. Task dependencies#
Add a List<Task> blockedBy field. Before allowing startProgress, check that all blocking tasks are DONE. This is a pre-condition guard in ToDoState.startProgress() — no other class needs to change.
3. Recurring tasks#
Add a RecurrenceRule value object (interval, end date). After completeTask() in DoneState, if a rule exists, create a clone task with the next due date via TaskManagementSystem.createTask(). Callers see no difference; the rule is self-contained.
4. Repository abstraction#
Replace the ConcurrentHashMap in TaskManagementSystem with a TaskRepository interface (findById, save, delete, findByStatus, findByAssignee). Swap in an InMemoryTaskRepository now and a JdbcTaskRepository later — TaskManagementSystem needs no changes.
5. Event / notification system#
After each state transition in setState(), publish a TaskEvent (Observer pattern). A NotificationService subscriber sends an email or push alert to the assignee. Decouples notifications from the core lifecycle without polluting Task.
6. Advanced search#
Introduce a TaskFilter builder that composes predicates — filter by priority range, due-date window, tag list, and assignee simultaneously. searchTasks accepts a TaskFilter instead of a bare keyword, and the Strategy still handles sorting — the two concerns stay separate.