Scalable Event-Driven Task System with TypeScript

Design a typed event-driven task system in TypeScript with modular architecture and reliable workflows

Time to implement the project: ~ 4-7 weeks

  • TypeScript
  • Event-Driven Architecture
  • Typed Domain Models
  • Async Task Processing
  • System Design
  • Ant Design
  • State Orchestration
  • Modular Services

In this advanced TypeScript project, you will build a scalable task management system that operates through an event-driven architecture. Instead of relying on direct function calls between modules, the system should react to domain events such as task creation, updates, assignment changes, and completion signals. Each event should trigger a predictable workflow that propagates through typed handlers and service modules.

The interface should allow users to monitor tasks, track status changes, and visualize activity streams while the underlying architecture remains decoupled and modular. Ant Design is well suited for this type of project because it provides structured components such as dashboards, tables, and notification systems that integrate smoothly with large TypeScript applications. The focus of this project is architecture clarity, event orchestration, and strongly typed domain logic.

Architectural Focus and Learning Value

Many applications start with tightly coupled modules that quickly become difficult to scale. Event-driven design solves this problem by separating actions from reactions. In this project, TypeScript types define every event, ensuring that all system components respond only to valid and predictable signals.

By implementing a typed event system, you will practice designing domain models, organizing services, and coordinating asynchronous behavior across modules. These patterns reflect how complex systems such as workflow engines, messaging platforms, and distributed services are structured.

Knowledge Required Before Starting

This project targets developers who already understand TypeScript fundamentals and are ready to design larger systems. You should feel comfortable modeling data structures, organizing code into services, and reasoning about asynchronous behavior.

  • Strong understanding of TypeScript generics, interfaces, and union types
  • Experience building modular TypeScript applications
  • Familiarity with asynchronous programming and event handling
  • Understanding of separation between domain logic and UI layers
  • Ability to design reusable service modules
  • Basic experience with UI component frameworks such as Ant Design
  • Confidence reading and structuring complex codebases

System Requirements and Core Features

The goal of this project is not simply to display tasks but to create a structured system where task-related events drive application behavior. The architecture should remain flexible enough to support future features without rewriting existing modules.

Requirement Explanation
Typed event definitions Every event in the system must have a defined TypeScript structure that guarantees predictable payload data.
Event dispatcher or broker A central dispatcher should broadcast events to registered handlers without creating tight coupling between modules.
Task lifecycle management The system should support task creation, updates, assignments, progress changes, and completion workflows.
Event handler modules Handlers should process specific events and perform domain logic without depending on unrelated modules.
Activity stream tracking The UI should display recent system events so users can observe task state transitions.
Dashboard-style interface Ant Design tables, cards, and navigation layouts should present system data clearly.
Typed domain models Task entities, event payloads, and service contracts must rely on strict TypeScript definitions.
Modular project architecture Separate directories should exist for domain models, event handlers, services, and UI components.
Scalable event handling logic The system should allow new event types and handlers to be added without rewriting existing code.
Consistent UI feedback Status updates, notifications, and activity logs should inform users about system events.

Implementation Guidance for Event-Driven Systems

Begin by defining your domain model before implementing UI elements. Create TypeScript interfaces for tasks and events, then build a dispatcher that routes events to the appropriate handlers. Each service should subscribe only to events it understands. This approach keeps the system modular and easy to extend.

Ant Design should structure the interface with dashboards, task lists, and activity panels while business logic remains separate. This separation ensures the application remains maintainable as the number of event types and system workflows grows. A well-defined event contract is the backbone of scalable event-driven architecture.

  • Define event types in a central domain layer
  • Use TypeScript generics to enforce event payload consistency
  • Keep handlers small and focused on single responsibilities
  • Separate UI components from domain logic
  • Design task lifecycle transitions carefully
  • Ensure event flows remain traceable through logs or activity streams
  • Document each event contract clearly
  • Design services so new handlers can be added safely

Common Mistakes When Building a Scalable Event-Driven Task System

1. Designing events as loose strings with untyped payloads

The biggest mistake in an event-driven TypeScript project is treating events as simple strings and payloads as flexible objects. This makes the system look event-driven, but it removes the safety that TypeScript should provide. If one handler expects taskId and another publishes id, the event will still be emitted, but the workflow may fail silently or create inconsistent task state.

Problematic approach:


          type EventPayload = Record<string, unknown>;

          function publish(eventName: string, payload: EventPayload): void {
            handlers[eventName]?.forEach((handler) => {
              handler(payload);
            });
          }

          publish("task.completed", {
            id: "task_123",
            user: "user_1"
          });

This code allows any event name and any payload shape. The compiler cannot warn you when required fields are missing or incorrectly named.

Better typed event map:


          type TaskStatus = "todo" | "in-progress" | "blocked" | "completed";

          interface DomainEvents {
            "task.created": {
              taskId: string;
              title: string;
              createdBy: string;
              createdAt: string;
            };

            "task.assigned": {
              taskId: string;
              assigneeId: string;
              assignedBy: string;
              assignedAt: string;
            };

            "task.status.changed": {
              taskId: string;
              previousStatus: TaskStatus;
              nextStatus: TaskStatus;
              changedBy: string;
              changedAt: string;
            };

            "task.completed": {
              taskId: string;
              completedBy: string;
              completedAt: string;
            };
          }

Typed publish function:


          type EventName = keyof DomainEvents;

          function publish<Name extends EventName>(
            eventName: Name,
            payload: DomainEvents[Name]
          ): void {
            const eventHandlers = handlers[eventName] || [];

            eventHandlers.forEach((handler) => {
              handler(payload);
            });
          }

          publish("task.completed", {
            taskId: "task_123",
            completedBy: "user_1",
            completedAt: new Date().toISOString()
          });

Pay attention to: Define an event contract before building handlers. Event names and payloads should be typed together so invalid events cannot move through the system unnoticed.

2. Letting event handlers change state without clear ownership

Event-driven systems can become messy when every handler is allowed to update any part of the application state. For example, a notification handler should not also update task status, write analytics, and modify assignment history. If handlers have unclear responsibilities, the system becomes hard to debug because one event can trigger many hidden side effects.

Problematic handler:


          async function onTaskAssigned(payload: any) {
            await db.task.update({
              where: { id: payload.taskId },
              data: { assigneeId: payload.assigneeId }
            });

            await db.notification.create({
              data: {
                userId: payload.assigneeId,
                message: "You have a new task."
              }
            });

            await db.analytics.create({
              data: {
                type: "assignment",
                taskId: payload.taskId
              }
            });

            await db.activityLog.create({
              data: {
                message: "Task was assigned."
              }
            });
          }

This handler does too many things. It mixes task mutation, notifications, analytics, and activity tracking in one place.

Better separation of handlers:


          type EventHandler<Name extends EventName> = (
            payload: DomainEvents[Name]
          ) => Promise<void> | void;

          const updateTaskAssignment: EventHandler<"task.assigned"> = async (payload) => {
            await taskRepository.assignTask({
              taskId: payload.taskId,
              assigneeId: payload.assigneeId
            });
          };

          const notifyAssignee: EventHandler<"task.assigned"> = async (payload) => {
            await notificationService.createNotification({
              userId: payload.assigneeId,
              message: "You have been assigned a new task."
            });
          };

          const recordAssignmentActivity: EventHandler<"task.assigned"> = async (payload) => {
            await activityService.record({
              type: "task.assigned",
              taskId: payload.taskId,
              actorId: payload.assignedBy,
              createdAt: payload.assignedAt
            });
          };

Registration example:


          eventBus.subscribe("task.assigned", updateTaskAssignment);
          eventBus.subscribe("task.assigned", notifyAssignee);
          eventBus.subscribe("task.assigned", recordAssignmentActivity);

Pay attention to: Handlers should be small and focused. One handler should own one reaction. This keeps workflows traceable and makes it easier to add, remove, or test system behavior.

3. Publishing events before the main task operation succeeds

Events should describe something that has happened, not something that might happen. A common mistake is publishing task.created before the task is actually saved. If the database operation fails, other handlers may still send notifications, update activity streams, or trigger follow-up workflows for a task that does not exist.

Problematic flow:


          async function createTask(input: CreateTaskInput): Promise<Task> {
            eventBus.publish("task.created", {
              taskId: input.id,
              title: input.title,
              createdBy: input.userId,
              createdAt: new Date().toISOString()
            });

            const task = await taskRepository.create(input);

            return task;
          }

This publishes the event too early. The event stream now claims a task was created even if the repository call fails.

Better command flow:


          async function createTask(input: CreateTaskInput): Promise<Task> {
            const task = await taskRepository.create({
              title: input.title,
              description: input.description,
              status: "todo",
              createdBy: input.userId
            });

            eventBus.publish("task.created", {
              taskId: task.id,
              title: task.title,
              createdBy: task.createdBy,
              createdAt: task.createdAt
            });

            return task;
          }

Safer outbox-style idea:


          async function createTask(input: CreateTaskInput): Promise<Task> {
            return db.transaction(async (tx) => {
              const task = await tx.task.create({
                data: {
                  title: input.title,
                  description: input.description,
                  status: "todo",
                  createdBy: input.userId
                }
              });

              await tx.eventOutbox.create({
                data: {
                  eventName: "task.created",
                  payload: {
                    taskId: task.id,
                    title: task.title,
                    createdBy: task.createdBy,
                    createdAt: task.createdAt
                  },
                  status: "pending"
                }
              });

              return task;
            });
          }

Pay attention to: Publish events only after the source operation is successful. For more reliable systems, store events in an outbox table and process them separately so task state and event history cannot drift apart.

4. Ignoring idempotency in async task handlers

Event handlers may run more than once. Retries, queue redelivery, browser refreshes, network interruptions, and worker restarts can all cause duplicate processing. If a handler sends the same notification twice, creates duplicate activity logs, or applies the same task transition repeatedly, users lose trust in the system.

Problematic handler:


          async function onTaskCompleted(payload: DomainEvents["task.completed"]) {
            await activityService.record({
              type: "task.completed",
              taskId: payload.taskId,
              actorId: payload.completedBy,
              createdAt: payload.completedAt
            });

            await notificationService.notifyManager({
              taskId: payload.taskId,
              message: "A task was completed."
            });
          }

If the same event is delivered twice, this handler records two activities and sends two notifications.

Better event envelope:


          interface EventEnvelope<Name extends EventName> {
            eventId: string;
            eventName: Name;
            payload: DomainEvents[Name];
            occurredAt: string;
          }

          type EnvelopeHandler<Name extends EventName> = (
            event: EventEnvelope<Name>
          ) => Promise<void>;

Idempotent processing:


          async function processOnce<Name extends EventName>(
            event: EventEnvelope<Name>,
            handler: () => Promise<void>
          ): Promise<void> {
            const existing = await processedEventRepository.findById(event.eventId);

            if (existing) {
              return;
            }

            await handler();

            await processedEventRepository.save({
              eventId: event.eventId,
              eventName: event.eventName,
              processedAt: new Date().toISOString()
            });
          }

          const onTaskCompleted: EnvelopeHandler<"task.completed"> = async (event) => {
            await processOnce(event, async () => {
              await activityService.record({
                type: event.eventName,
                taskId: event.payload.taskId,
                actorId: event.payload.completedBy,
                createdAt: event.payload.completedAt
              });
            });
          };

Pay attention to: Every event should have a unique event ID. Handlers that create side effects should be safe to retry without duplicating work.

5. Building the dashboard UI directly on live internal events

An activity stream is useful, but the UI should not depend on raw internal event objects. Internal events may contain technical fields, sensitive data, or payload shapes that change as the system evolves. Beginners often render the event payload directly in a table, which creates a dashboard that is difficult to understand and easy to break.

Problematic Ant Design table:


          <Table
            dataSource={events}
            columns={[
              {
                title: "Event",
                dataIndex: "eventName"
              },
              {
                title: "Payload",
                render: (_, event) => JSON.stringify(event.payload)
              }
            ]}
          />

This exposes raw technical data instead of useful product information. Users should see meaningful activity such as “Task assigned to Maria,” not a JSON object.

Better view model:


          interface ActivityItem {
            id: string;
            title: string;
            description: string;
            taskId: string;
            actorId: string;
            createdAt: string;
            severity: "info" | "success" | "warning";
          }

          function eventToActivityItem(
            event: EventEnvelope<EventName>
          ): ActivityItem {
            switch (event.eventName) {
              case "task.created":
                return {
                  id: event.eventId,
                  title: "Task created",
                  description: event.payload.title,
                  taskId: event.payload.taskId,
                  actorId: event.payload.createdBy,
                  createdAt: event.occurredAt,
                  severity: "info"
                };

              case "task.completed":
                return {
                  id: event.eventId,
                  title: "Task completed",
                  description: "A task was marked as completed.",
                  taskId: event.payload.taskId,
                  actorId: event.payload.completedBy,
                  createdAt: event.occurredAt,
                  severity: "success"
                };
            }
          }

Cleaner Ant Design rendering:


          <Table
            rowKey="id"
            dataSource={activityItems}
            columns={[
              {
                title: "Activity",
                dataIndex: "title"
              },
              {
                title: "Description",
                dataIndex: "description"
              },
              {
                title: "Time",
                dataIndex: "createdAt",
                render: (value) => formatDateTime(value)
              }
            ]}
          />

Pay attention to: Convert internal events into UI-specific activity items before rendering. The dashboard should show clear workflow history, not raw system internals.

By completing this project, you'll gain hands-on experience designing a scalable TypeScript system that uses event-driven architecture to coordinate application behavior. You will practice defining domain models, implementing typed event flows, and organizing services into modular layers. The project demonstrates how large TypeScript applications maintain flexibility while remaining predictable and maintainable as complexity grows.

Reference Implementations Worth Studying

Workflow and event-bus reference:
paulingalls - TS-Flow

This repository is the closest conceptual reference for the workflow side of the project. TS-Flow is a TypeScript-based workflow automation system built around a core event bus and an inversion-of-control container pattern. It uses JSON-defined workflows, where nodes communicate through events and workflows can include triggers, query engines, endpoints, API integrations, AI operations, Slack actions, scheduled execution, and human-in-the-loop interactions.

Pay particular attention to:

  • How workflows can be described as connected nodes instead of direct function calls.
  • How the event bus allows components to publish and subscribe without tight coupling.
  • How trigger nodes, processing nodes, and endpoint nodes represent different workflow responsibilities.
  • How JSON workflow definitions can make behavior configurable without rewriting application code.
  • How custom node types can extend the system when new integrations or task actions are needed.

Use this repository as the main workflow reference. Your own task system does not need to copy its full plugin model, but it should learn from the way events connect independent units of work.

Modular event-driven library reference:
artandrey - Event Driven

This implementation is useful because it focuses on event-driven architecture as a reusable TypeScript library rather than a full dashboard product. It is described as a modular, framework-agnostic library for building event-driven architectures in TypeScript server-side applications. The repository includes core definitions and interfaces, plus a BullMQ wrapper, with future transport ideas such as RabbitMQ, SQS, PgMQ, and Kafka.

When studying the code, focus on:

  • How core event definitions and interfaces can stay separate from transport-specific implementations.
  • How a queue wrapper changes event handling from in-memory dispatch to async processing.
  • Why framework-agnostic design makes event logic easier to reuse across different apps.
  • How package boundaries can keep infrastructure concerns away from domain logic.
  • How your own task system could start with an in-memory event bus and later move toward queue-backed processing.

Use this repository as the architectural comparison point. It is especially helpful for understanding the difference between domain events, queue infrastructure, and the application code that reacts to those events.

Production-scale backend framework reference:
puristajs - PURISTA

This repository is the strongest production-level alternative. PURISTA is a TypeScript backend framework for building modular and scalable services and APIs with event-driven patterns. It combines ideas from domain-driven design, CQRS, microservices, event sourcing, and lambda functions, while focusing on schemas, generated types, input-output validation, OpenAPI documentation, and OpenTelemetry tracing.

While reviewing this project, examine:

  • How commands and subscriptions separate direct requests from event-based reactions.
  • How schema-driven validation makes service contracts safer and easier to document.
  • How a system can run as a single instance, microservices, cloud functions, or edge/IoT deployments without rewriting business logic.
  • How observability features such as tracing matter once workflows become distributed.
  • How queue and worker scaffolding can support long-running or asynchronous task workloads.

Use this implementation as a production-scale reference, not as the first version to copy. For your portfolio project, build the typed event contracts and task workflows yourself first; then study PURISTA to understand how mature backend frameworks formalize similar ideas.

© 2026 ReadyToDev.Pro. All rights reserved.

Methodology

Privacy Policy

Terms & Conditions