Task Management System LLD
An in-depth Low-Level Design (LLD) of an enterprise Task Management System featuring custom LRU caching, the Repository pattern, and thread-safe DAO patterns.
Task Management System: Low-Level Design (LLD)
A modular, production-grade Low-Level Design (LLD) of an in-memory Task Management System built in pure Java. This project models core enterprise patterns for managing task lifecycles, state transitions, and assignments, reinforced with an integrated, custom Least Recently Used (LRU) Cache backed by a Doubly Linked List and HashMap, and a Domain-Driven Design (DDD) Event-Driven Notification Subsystem based on the Observer Pattern.
📌 Table of Contents
- What We Are Building
- System Architecture
- Class Blueprint & Domain Models
- Design Patterns Applied
- 1. Domain-Driven Design: Aggregate Root & Domain Events
- 2. Observer Pattern: Generic Event Publisher & Subscribers
- 3. Data Transfer Object (DTO) Pattern: The Web Client Gate
- 4. Repository Pattern
- 5. Data Access Object (DAO) Pattern
- 6. Singleton Pattern (Double-Checked Locking)
- 7. Builder Pattern
- 8. Cache-Aside & Write-Through Caching
- 9. O(1) LRU Eviction via Doubly Linked List
- Notification Subsystem & Event Architecture
- Core Workflows & Sequence Diagrams
- Complexity Analysis
- Project Structure
- Running the Application
🎯 What We Are Building
In enterprise software engineering, task and project tracking backends (such as Jira, Linear, or Asana) face several fundamental challenges:
- High Read-to-Write Ratio: Tasks are viewed and inspected far more frequently than they are updated.
- Strict Domain Invariants: Invalid dates, null assignees during transitions, and empty task payloads must be rejected at the domain boundary.
- Data Access Decoupling: Business logic must not be tightly coupled to underlying storage engines (in-memory maps, SQL databases, or distributed caches like Redis).
This project demonstrates how to solve these challenges using a multi-tiered, clean architecture:
- Rich Aggregate Root (
Task): Encapsulates business invariants and records domain events internally upon state transitions. - Service Layer Gate (
TaskService): Orchestrates persistence viaTaskRepository, accepts single-request DTOs, and flushes domain events. - Data Transfer Objects (
UpdateTaskRequest): Prevents endpoint explosion by accepting composite updates from web clients atomically. - Generic Notification Engine (
Event<T>,EventPublisher,Subscriber): Fully decoupled Observer pattern framework ready for any entity (Tasks, Stories, Spikes). - Unified Repository Layer (
TaskRepository): Orchestrates reads and writes across both persistent storage and cache layers. - Custom In-Memory LRU Cache (
InMemoryCache): Built from fundamental data structures (DoublyLinkedListandHashMap) supporting constant-time $O(1)$ access and eviction. - Persistent In-Memory DAO (
InMemoryTaskDao): Implements thread-safe singleton storage with double-checked locking.
🏗 System Architecture
The project enforces clean unidirectional flow between DTO presentation, service orchestration, persistence, and event notification:
flowchart TD
Client["Web Client / Main Entrypoint"] -->|"UpdateTaskRequest (DTO)"| Service["TaskService<br/>Service Boundary & Gate"]
subgraph Domain_Model ["Domain Model (DDD)"]
Service -->|"1. Mutate State"| Task["Task (Aggregate Root)<br/>Records DomainEvents on change"]
end
subgraph Persistence_Layer ["Persistence Layer"]
Service -->|"2. Persist Task"| Repository["TaskRepository<br/>Cache & Storage Orchestration"]
Repository -->|"Read Hit / Put / Evict"| Cache["CacheDao<br/>InMemoryCache - LRU"]
Repository -->|"Read Miss / Write / Delete"| DB["TaskDao<br/>InMemoryTaskDao - Storage"]
Cache --> DLL["DoublyLinkedList<br/>O(1) Sentinel Recency"]
Cache --> Map["HashMap of UUID, Node<br/>O(1) Direct Lookup"]
end
subgraph Notification_Subsystem ["Notification Subsystem (Observer Pattern)"]
Service -->|"3. Pull & Publish Events"| Publisher["EventPublisher<br/>SimpleEventPublisher"]
Publisher -->|"4. Broadcast Event"| Sub1["Subscriber<br/>EmailNotifier"]
Publisher -->|"4. Broadcast Event"| Sub2["Subscriber<br/>SlackNotifier / Logger"]
end
Layer Responsibilities
| Layer | Component | Responsibility |
|---|---|---|
| DTO Layer | UpdateTaskRequest | Encapsulates optional fields submitted by client in a single atomic payload. |
| Service Layer | TaskService | Acts as the gate. Fetches aggregates, delegates domain operations, persists, and flushes events. |
| Domain Layer | Task, User, Status | Aggregate Root enforcing invariants and generating domain events on state change. |
| Notification Layer | Event<T>, EventPublisher, Subscriber | Generic, domain-agnostic pub-sub notification engine. |
| Repository Layer | TaskRepository | Mediates between cache and database (Cache-Aside, Write-Through). |
| Cache Layer | CacheDao / InMemoryCache | $O(1)$ LRU cache maintaining active hot records. |
| Persistence Layer | TaskDao / InMemoryTaskDao | Thread-safe in-memory authoritative storage. |
| Data Structures | DoublyLinkedList, Node | Custom generic doubly linked list utilizing sentinel head/tail nodes. |
📐 Class Blueprint & Domain Models
classDiagram
%% ----------------- DOMAIN & DTO -----------------
class Status {
<<enumeration>>
TODO
IN_PROGRESS
DONE
}
class User {
-String username
-UUID id
+getUsername() String
+getId() UUID
+updateUserName(String)
-validateUserName(String)
}
class Task {
-UUID id
-String name
-String description
-User assignedTo
-LocalDate createdAt
-LocalDate modifiedAt
-LocalDate dueDate
-Status status
-List~Event~ domainEvents
+getId() UUID
+getName() String
+getDescription() String
+getAssignedTo() User
+getDueDate() LocalDate
+getStatus() Status
+updateAssignee(User)
+updateDueDate(LocalDate)
+updateStatus(Status)
+updateName(String)
+updateDescription(String)
+pullDomainEvents() List~Event~
}
class UpdateTaskRequest {
<<record>>
+UUID uuid
+String name
+String description
+User assignedTo
+LocalDate dueDate
+Status status
}
%% ----------------- NOTIFICATION & OBSERVER -----------------
class Event~T~ {
<<interface>>
+getEntityId() UUID
+getEventName() String
+getOldValue() T
+getNewValue() T
+getEventTime() Instant
}
class TaskAssigneeChangeEvent {
-UUID entityId
-User oldValue
-User newValue
-Instant time
+getEntityId() UUID
+getEventName() String
+getOldValue() User
+getNewValue() User
+getEventTime() Instant
}
class EventPublisher {
<<interface>>
+addSubscriber(Subscriber)
+removeSubscriber(Subscriber)
+notify(Event~T~)
}
class SimpleEventPublisher {
-List~Subscriber~ subscribers
+addSubscriber(Subscriber)
+removeSubscriber(Subscriber)
+notify(Event~T~)
}
class Subscriber {
<<interface>>
+consume(Event~T~)
}
class EmailNotifier {
+consume(Event~T~)
}
%% ----------------- SERVICE & REPOSITORY -----------------
class TaskService {
-TaskRepository repository
-EventPublisher publisher
+getTask(UUID) Task
+getAllTasks() List~Task~
+saveTask(Task) UUID
+updateTask(UpdateTaskRequest)
+updateTask(Task)
+deleteTask(UUID)
}
class TaskRepository {
-CacheDao cacheDao
-TaskDao taskDao
+getTask(UUID) Task
+getAllTask() List~Task~
+saveTask(Task)
+updateTask(Task)
+deleteTask(UUID)
}
%% ----------------- STORAGE & CACHE -----------------
class TaskDao {
<<interface>>
+getTask(UUID) Optional~Task~
+getAllTask() List~Task~
+saveTask(Task) Optional~UUID~
+updateTask(Task)
+deleteTask(UUID)
}
class InMemoryTaskDao {
-volatile InMemoryTaskDao INSTANCE$
-Map~UUID, Task~ taskMap
+getInstance()$ InMemoryTaskDao
}
class CacheDao {
<<interface>>
+get(UUID) Optional~Task~
+put(Task)
+remove(UUID)
}
class InMemoryCache {
-Map~UUID, Node~Task~~ taskMap
-DoublyLinkedList linkedList
-int capacity
+get(UUID) Optional~Task~
+put(Task)
+remove(UUID)
}
class DoublyLinkedList~V, N~ {
~N head
~N tail
~int size
+addToHead(Node~V~)
+remove(Node~V~)
+removeTail() Optional~UUID~
}
%% Relationships
Task --> Status : has
Task --> User : assignedTo
Task o-- Event : records
Event <|.. TaskAssigneeChangeEvent : implements
EventPublisher <|.. SimpleEventPublisher : implements
Subscriber <|.. EmailNotifier : implements
SimpleEventPublisher o-- Subscriber : notifies
TaskService --> TaskRepository : persists via
TaskService --> EventPublisher : flushes events to
TaskService ..> UpdateTaskRequest : accepts
TaskRepository --> CacheDao : caches in
TaskRepository --> TaskDao : queries
TaskDao <|.. InMemoryTaskDao : implements
CacheDao <|.. InMemoryCache : implements
InMemoryCache --> DoublyLinkedList : tracks recency
💡 Design Patterns Applied
1. Domain-Driven Design: Aggregate Root & Domain Events
- Problem: Service-layer diffing (
if (!existing.getAssignee().equals(incoming.getAssignee()))) is error-prone, violates DRY, and clutters orchestration code with manual property comparisons. - Solution:
Taskacts as an Aggregate Root (similar to Spring Data’sAbstractAggregateRoot). When state-changing methods (updateAssignee,updateStatus,updateDueDate) are invoked,Taskencapsulates its own business invariants and appends an immutable domain event to an internal buffer.TaskServicesimply pulls uncommitted events (task.pullDomainEvents()) and dispatches them after persisting.
// Inside Task (Aggregate Root):
public void updateAssignee(User user) {
if (Objects.equals(this.assignedTo, user)) return; // Guard against duplicate no-op
User oldAssignee = this.assignedTo;
this.assignedTo = user;
this.domainEvents.add(new TaskAssigneeChangeEvent(this.id, oldAssignee, user));
updateLastModifiedDate();
}
2. Observer Pattern: Generic Event Publisher & Subscribers
- Problem: Tying notifications directly to
Taskprevents reusing the notification infrastructure for other entities (e.g.,Story,Spike,Project). - Solution: The notification framework is completely generic and domain-agnostic:
Event<T>providesgetEntityId(),getEventName(),getOldValue(),getNewValue(), andgetEventTime().EventPublishermaintains a list ofSubscribers and broadcasts events.Subscriberimplementations (EmailNotifier) process any event without needing hardcoded task dependencies.
// Generic Subscriber consumes any event safely:
public class EmailNotifier implements Subscriber {
@Override
public <T> void consume(Event<T> event) {
String oldVal = event.getOldValue() == null ? "None" : event.getOldValue().toString();
String newVal = event.getNewValue() == null ? "None" : event.getNewValue().toString();
System.out.println("📧 [EMAIL NOTIFICATION] Event: " + event.getEventName()
+ " | Entity ID: " + event.getEntityId()
+ " | Old: " + oldVal
+ " -> New: " + newVal
+ " | At: " + event.getEventTime());
}
}
3. Data Transfer Object (DTO) Pattern: The Web Client Gate
- Problem: Over HTTP/REST, a web client cannot mutate in-memory Java objects. Forcing individual endpoints for every single field (
PATCH /assignee,PATCH /status) produces network chatty-ness and partial failure risks. - Solution:
UpdateTaskRequestis an immutable record that encapsulates all optional fields submitted in a single request.TaskServiceacts as the gate: it loads the aggregate, executes only the relevant domain mutations, saves once, and flushes all resulting events atomically.
public void updateTask(UpdateTaskRequest request) {
Task task = repository.getTask(request.uuid());
if (request.name() != null) task.updateName(request.name());
if (request.description() != null) task.updateDescription(request.description());
if (request.assignedTo() != null) task.updateAssignee(request.assignedTo());
if (request.dueDate() != null) task.updateDueDate(request.dueDate());
if (request.status() != null) task.updateStatus(request.status());
repository.updateTask(task);
task.pullDomainEvents().forEach(publisher::notify);
}
4. Repository Pattern
- Problem: When business services directly interact with both databases and caches, caching logic leaks throughout the service tier, producing code duplication and subtle bugs.
- Solution:
TaskRepositoryacts as an in-memory collection-like interface.TaskServicedoes not know whether a task came from a fast memory cache or an underlying database; the repository seamlessly orchestrates lookups, saves, and updates.
5. Data Access Object (DAO) Pattern
- Problem: Directly exposing storage implementations tightly binds domain rules to technical storage choices.
- Solution: We define
TaskDaoandCacheDaointerfaces.InMemoryTaskDaoandInMemoryCacheprovide concrete implementations. If we decide to swap outInMemoryTaskDaofor a relational SQL database via JDBC/Hibernate, orInMemoryCachefor Redis, zero changes are required inTaskRepositoryorTaskService.
6. Singleton Pattern (Double-Checked Locking)
- Problem: Multiple concurrent instances of an in-memory database DAO would fragment stored records into isolated states.
- Solution:
InMemoryTaskDaoimplements the Thread-Safe Singleton Pattern using avolatileinstance reference and Double-Checked Locking. This avoids continuous synchronization bottlenecks while guaranteeing single-instance initialization.
public class InMemoryTaskDao implements TaskDao {
private static volatile InMemoryTaskDao INSTANCE;
private final Map<UUID, Task> taskMap;
private InMemoryTaskDao() {
taskMap = new HashMap<>();
}
public static InMemoryTaskDao getInstance() {
if (INSTANCE == null) {
synchronized (InMemoryTaskDao.class) {
if (INSTANCE == null) {
INSTANCE = new InMemoryTaskDao();
}
}
}
return INSTANCE;
}
}
7. Builder Pattern
- Problem: Initializing a cache with multiple optional tuning parameters (initial capacity, eviction boundaries, load factors) via telescoping constructors is error-prone.
- Solution:
InMemoryCache.Builderprovides a readable, fluent configuration interface while enforcing validation invariants (e.g., verifying capacity $> 0$) prior to constructing the immutable cache instance.
CacheDao cacheDao = new InMemoryCache.Builder()
.capacity(2)
.build();
8. Cache-Aside & Write-Through Caching
The repository employs a coordinated dual-datasource strategy:
Read (Cache-Aside with Lazy Loading):
- Query
CacheDao. - Cache Hit: Return item immediately. The underlying LRU cache promotes the item to the head of its recency list.
- Cache Miss: Retrieve the item from
TaskDao(database). - Store the fetched item into
CacheDaofor subsequent requests. - Return the item.
Write / Update (Write-Through):
- Update
TaskDao. - Simultaneously update/refresh
CacheDao(cacheDao.put(task)), ensuring immediate read-your-own-writes consistency.
Delete (Cache Invalidation):
- Remove from
CacheDao. - Remove from
TaskDao.
9. O(1) LRU Eviction via Doubly Linked List
The InMemoryCache achieves constant time $O(1)$ lookup, insertion, update, and eviction by combining:
HashMap<UUID, Node<Task>>: Provides $O(1)$ random access directly to any node in the list.DoublyLinkedList<Task, Node<Task>>: Maintains access order without requiring array shifts.- Uses dummy sentinel nodes (
headandtail) to eliminate null pointer checks at list boundaries. - On Access (
get): The accessed node is decoupled from its current position in $O(1)$ and spliced right afterhead. - On Overflow (
putwhensize >= capacity): The least recently used node immediately beforetail(tail.prev) is evicted from both the list and the hash map in $O(1)$.
- Uses dummy sentinel nodes (
[ Head Sentinel ] <---> [ Most Recent ] <---> ... <---> [ Least Recent ] <---> [ Tail Sentinel ]
^
(Evicted on Overflow)
🔔 Notification Subsystem & Event Architecture
The notification system models an enterprise Event-Driven Observer Pattern designed around domain lifecycle state transitions:
State Changes Tracked
- Assignee Change (
TaskAssigneeChangeEvent): Emitted when a task is assigned, reassigned, or unassigned. - Due Date Change: Notifies stakeholders of deadline postponements or escalations.
- Status Change: Signals state movement across
TODO -> IN_PROGRESS -> DONE.
Event Dispatch & Notification Workflow
sequenceDiagram
autonumber
actor Client as "Web Client / Main"
participant Service as "TaskService (The Gate)"
participant Repo as TaskRepository
participant Task as "Task (Aggregate Root)"
participant Pub as SimpleEventPublisher
participant Sub as EmailNotifier
Note over Client,Sub: 1. Client Submits Update Request DTO
Client->>Service: updateTask(UpdateTaskRequest)
Service->>Repo: getTask(uuid)
Repo-->>Service: Task (from Cache/DB)
Note over Service,Task: 2. Domain Model Executes & Records Events
Service->>Task: task.updateAssignee(alice)
Task->>Task: Check change != current
Task->>Task: Append TaskAssigneeChangeEvent to domainEvents
Note over Service,Repo: 3. Persistence
Service->>Repo: updateTask(task)
Repo->>Repo: Write to DB & refresh Cache
Note over Service,Sub: 4. Flush Uncommitted Events to Observers
Service->>Task: task.pullDomainEvents()
Task-->>Service: [TaskAssigneeChangeEvent]
Service->>Pub: notify(event)
Pub->>Sub: consume(event)
Sub->>Sub: Print / Dispatch email notification
Architectural Guarantees:
- Zero Service-Side Diffing: The entity tracks what changed when it changed, capturing
oldValueandnewValueautomatically. - Generic & Domain-Agnostic Engine:
Event<T>,EventPublisher, andSubscriberdo not depend onTask. They work for any entity (e.g., Stories, Spikes). - Atomic Single-Request Updates: Web clients submit one DTO payload;
TaskServiceprocesses all mutations in one unit of work.
🔄 Core Workflows & Sequence Diagrams
1. Task Retrieval (Cache Miss -> DB Fetch -> Cache Backfill)
sequenceDiagram
autonumber
actor Client
participant Service as TaskService
participant Repo as TaskRepository
participant Cache as InMemoryCache
participant DB as InMemoryTaskDao
Client->>Service: getTask(uuid)
Service->>Repo: getTask(uuid)
Repo->>Cache: get(uuid)
alt Cache Hit
Cache-->>Repo: Optional.of(task) (Promoted to Head)
Repo-->>Service: Task
Service-->>Client: Task
else Cache Miss
Cache-->>Repo: Optional.empty()
Repo->>DB: getTask(uuid)
DB-->>Repo: Task
Repo->>Cache: put(task)
Repo-->>Service: Task
Service-->>Client: Task
end
2. LRU Eviction on Capacity Exceeded
sequenceDiagram
autonumber
participant Repo as TaskRepository
participant Cache as InMemoryCache
participant DLL as DoublyLinkedList
participant Map as taskMap
Repo->>Cache: put(task3)
Cache->>Cache: Check capacity (size >= capacity)
Cache->>DLL: removeTail()
DLL->>DLL: Decouple tail.prev
DLL-->>Cache: Optional.of(evictedUUID)
Cache->>Map: remove(evictedUUID)
Cache->>DLL: addToHead(newNode)
Cache->>Map: put(task3.getId(), newNode)
Cache-->>Repo: void
📊 Complexity Analysis
| Operation | Time Complexity | Space Complexity | Description |
|---|---|---|---|
getTask(id) (Cache Hit) | $O(1)$ | $O(1)$ | Hash lookup + DLL pointer manipulation |
getTask(id) (Cache Miss) | $O(1)$ | $O(1)$ | DB hash lookup + Cache insertion |
saveTask(task) | $O(1)$ | $O(1)$ | Validation + DB insert + Cache insert |
updateTask(task) | $O(1)$ | $O(1)$ | DB replace + Cache promote/insert |
deleteTask(id) | $O(1)$ | $O(1)$ | DB remove + Cache node detachment |
getAllTasks() | $O(N)$ | $O(N)$ | Iterating over all active tasks in DB |
Where $C$ is cache capacity and $N$ is total number of tasks in the database.
📁 Project Structure
task-management-system/
├── build.gradle # Gradle build script
├── settings.gradle # Gradle project settings
├── gradlew / gradlew.bat # Gradle wrapper scripts
├── README.md # Architecture & design documentation
└── src/
└── main/
└── java/
└── com/
└── jyotimoykashyap/
├── Main.java # Interactive lifecycle & notification demo
├── models/
│ ├── Status.java # Task status enum (TODO, IN_PROGRESS, DONE)
│ ├── User.java # User domain model with validations & ID
│ └── Task.java # Aggregate Root with domain event buffer
├── dto/
│ └── UpdateTaskRequest.java # Immutable DTO for atomic client updates
├── datastructures/
│ ├── Node.java # Generic Doubly-Linked List Node
│ └── DoublyLinkedList.java # Custom O(1) Doubly Linked List with Sentinels
├── cache/
│ ├── CacheDao.java # Cache contract interface
│ └── InMemoryCache.java # LRU Cache implementation with Builder
├── dao/
│ ├── TaskDao.java # Task persistence contract interface
│ └── InMemoryTaskDao.java # Singleton In-Memory Task Database
├── repository/
│ └── TaskRepository.java # Coordinates Cache & DAO access
├── service/
│ └── TaskService.java # Public service gate & event dispatcher
└── notification/
├── event/
│ ├── Event.java # Generic event interface (entityId, eventName)
│ └── TaskAssigneeChangeEvent.java # Strongly-typed assignee change event
├── publisher/
│ ├── EventPublisher.java # Publisher contract
│ └── SimpleEventPublisher.java # Concrete broadcast publisher
└── subscriber/
├── Subscriber.java # Generic subscriber consumer contract
└── EmailNotifier.java # Concrete email subscriber
🚀 Running the Application
1. Prerequisites
- JDK 21 or higher installed.
2. Build the Project
./gradlew build
3. Run the Demonstration
The included Main.java configures a cache capacity of 2 to clearly showcase LRU eviction, cache hits, cache misses, updates, and email notifications:
# Compile and run via java directly:
./gradlew compileJava
java -cp build/classes/java/main com.jyotimoykashyap.Main
Expected Output
=== Starting Task Management System Demo ===
--- 1. Creating and Saving Tasks ---
Saved Task 1: 2d2d1937-d5e6-401c-b036-13083437adfa
Saved Task 2: 8ed4ffb2-c9b6-4054-b2e8-f7e7d4ff7a69
--- 2. Fetching Task 1 (Cache Hit & LRU Promotion) ---
Successfully fetched Task 1: 2d2d1937-d5e6-401c-b036-13083437adfa
--- 3. Saving Task 3 (Triggers LRU Eviction of Task 2) ---
Saved Task 3: 215231fb-db0d-494b-a836-2660ce6f4b4d
--- 4. Fetching Task 2 (Cache Miss -> DB Fetch) ---
Successfully fetched Task 2 from DB: 8ed4ffb2-c9b6-4054-b2e8-f7e7d4ff7a69
--- 5. Updating Task 1 (Assignee Change & Email Notification) ---
📧 [EMAIL NOTIFICATION] Event: TASK_ASSIGNEE_CHANGED | Entity ID: 2d2d1937-d5e6-401c-b036-13083437adfa | Old: None -> New: alice | At: 2026-09-10T18:17:48.802015Z
Updated Task 1 via UpdateTaskRequest successfully.
--- 6. Listing All Tasks ---
Total tasks in system: 3
- Task ID: 2d2d1937-d5e6-401c-b036-13083437adfa
- Task ID: 215231fb-db0d-494b-a836-2660ce6f4b4d
- Task ID: 8ed4ffb2-c9b6-4054-b2e8-f7e7d4ff7a69
--- 7. Deleting Task 3 ---
Deleted Task 3.
Verified: Task 3 no longer exists (Task not found)
=== All Tests Completed Successfully! ===
🔮 Concurrency & Future Roadmap
While the current database DAO leverages thread-safe Singleton instantiation and maps, under heavy multi-threaded workloads the following enhancements can be incorporated:
- Concurrent Data Structures: Replacing raw
HashMapwithConcurrentHashMapand wrapping doubly linked list operations in aReentrantReadWriteLock. - Asynchronous Dispatching: Backing
EventPublisherwith anExecutorService(or virtual threads via Project Loom) to process subscriber delivery on background threads. - Pluggable Eviction Strategies: Refactoring
InMemoryCacheto support pluggable policies (LFU, FIFO, Clock-Pro) via the Strategy Pattern (similar tocache-lld).