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.

August 13, 2026·15 min read

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#

EntityResponsibility
TaskCore entity — holds all task data; delegates state transitions to its TaskState; thread-safe mutation methods
Task.TaskBuilderFluent builder — constructs Task with required title and optional fields
EpicGroups tasks; CopyOnWriteArrayList for thread-safe iteration
UserImmutable record — id, name, email
CommentImmutable record — content, author, timestamp
TagImmutable label attached to a task
TaskActivityLogTimestamped string appended on every task mutation
TaskStateInterface — startProgress, completeTask, reopenTask, getStatus
ToDoStateAllows startProgress; rejects complete and reopen
InProgressStateAllows complete and reopen; rejects startProgress
DoneStateAllows reopen; rejects start and complete
TaskSortStrategyInterface — sort(List<Task>); enables swappable sorting
SortByDueDateSorts ascending by due date
SortByPrioritySorts descending by priority enum ordinal
TaskManagementSystemSingleton — 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#

java
public enum TaskPriority {
    LOW, MEDIUM, HIGH, CRITICAL
}

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.

java
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; }
}

State Pattern — Task Lifecycle#

TaskState encodes each lifecycle stage; only valid transitions are allowed.

java
public interface TaskState {
    void startProgress(Task task);
    void completeTask(Task task);
    void reopenTask(Task task);
    TaskStatus getStatus();
}

Strategy Pattern — Sorting#

java
import java.util.List;

public interface TaskSortStrategy {
    void sort(List<Task> tasks);
}

Singleton — TaskManagementSystem#

Central coordinator with thread-safe registries and all CRUD / search / filter operations.

java
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#

java
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.

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.