DEV Community

DEV-AI
DEV-AI

Posted on

Real-Time Task Lifecycle Tracking: .NET Wrapper over Flowable with Event-Driven DB Sync

Extending the .NET–Flowable Integration: Delegation, Comments, Priority, and Beyond

Beyond claiming and completing tasks, real-world workflow systems require handling delegation chains, audit comments, file attachments, due-date escalation, priority sorting, multi-instance tasks, and fine-grained identity link management. This section extends the .NET wrapper to cover these remaining task-related capabilities, each backed by its own tracking table so the read model stays in sync in real time.

1. Delegation and Task Handover Actions

Flowable distinguishes between reassignment (task permanently moves to another user) and delegation (task temporarily moves to another user but returns to the original owner upon resolution). Delegation sets the original assignee as owner and marks the task's DelegationState as PENDING; when the delegate finishes, calling resolveTask flips it back to RESOLVED and returns control to the owner.

public async Task DelegateTaskAsync(string taskId, string fromUserId, string toUserId)
{
    var payload = new { action = "delegate", assignee = toUserId };
    await _httpClient.PostAsJsonAsync($"runtime/tasks/{taskId}", payload);
    // Owner is preserved as fromUserId automatically by Flowable
}

public async Task ResolveTaskAsync(string taskId, Dictionary<string, object> variables)
{
    var payload = new { action = "resolve", variables };
    await _httpClient.PostAsJsonAsync($"runtime/tasks/{taskId}", payload);
}

public async Task ReassignTaskAsync(string taskId, string newAssignee)
{
    var payload = new { assignee = newAssignee };
    await _httpClient.PutAsJsonAsync($"runtime/tasks/{taskId}", payload);
}
Enter fullscreen mode Exit fullscreen mode

Track delegation state explicitly in your read model so dashboards can distinguish "pending delegation" from ordinary in-progress tasks:

ALTER TABLE task_state ADD COLUMN delegation_state VARCHAR(16); -- PENDING, RESOLVED, NULL
ALTER TABLE task_state ADD COLUMN delegated_by VARCHAR(128);
Enter fullscreen mode Exit fullscreen mode

2. Comments and Attachments

Flowable supports attaching free-text comments and binary/URL-based attachments to both tasks and process instances, which is essential for audit trails and collaborative approval flows. The .NET wrapper should mirror these into local tables rather than fetching them from Flowable on every page load.

public async Task AddCommentAsync(string taskId, string userId, string message)
{
    var payload = new { message, saveProcessInstanceId = true };
    var response = await _httpClient.PostAsJsonAsync($"runtime/tasks/{taskId}/comments", payload);
    var comment = await response.Content.ReadFromJsonAsync<CommentDto>();
    // Persist to local task_comments table for fast retrieval
}

public async Task<AttachmentDto> AddAttachmentAsync(string taskId, string name, string description, Stream content)
{
    using var form = new MultipartFormDataContent();
    form.Add(new StreamContent(content), "file", name);
    form.Add(new StringContent(name), "name");
    form.Add(new StringContent(description), "description");
    var response = await _httpClient.PostAsync($"runtime/tasks/{taskId}/attachments", form);
    return await response.Content.ReadFromJsonAsync<AttachmentDto>();
}
Enter fullscreen mode Exit fullscreen mode
CREATE TABLE task_comments (
    id BIGSERIAL PRIMARY KEY,
    task_id VARCHAR(64) NOT NULL,
    process_instance_id VARCHAR(64),
    author VARCHAR(128),
    message TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL
);

CREATE TABLE task_attachments (
    id BIGSERIAL PRIMARY KEY,
    task_id VARCHAR(64) NOT NULL,
    attachment_name VARCHAR(256),
    attachment_type VARCHAR(64),
    url TEXT,
    created_at TIMESTAMPTZ NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

3. Priority, Due Dates, and Escalation

Tasks default to priority 50 if unspecified, and can be sorted by combining orderByPriority and orderByDueDate in queries, which is critical for building "urgent tasks first" dashboards. .NET should expose sortable read-model columns so this logic never needs a live REST call.

public async Task SetTaskPriorityAsync(string taskId, int priority, DateTime? dueDate)
{
    var payload = new { priority, dueDate = dueDate?.ToString("o") };
    await _httpClient.PutAsJsonAsync($"runtime/tasks/{taskId}", payload);
}

public async Task<List<TaskState>> GetTasksSortedAsync(string groupId)
{
    return await _db.TaskState
        .Where(t => t.CandidateGroup == groupId && t.Status != "COMPLETED")
        .OrderByDescending(t => t.Priority)
        .ThenBy(t => t.DueDate)
        .ToListAsync();
}
Enter fullscreen mode Exit fullscreen mode

For proactive alerts, Flowable can trigger SLA escalation steps via timer jobs that automatically raise priority or reassign a task when a due date is approaching. Capture the corresponding TASK_DUE_DATE_CHANGED and escalation events in the Java bridge and project them into task_state.priority, so escalations reflect immediately in the .NET dashboard without polling.

4. Multi-Instance Tasks

Multi-instance tasks spawn several parallel or sequential task instances from a single BPMN activity (e.g., "N approvers must sign off"), and Flowable exposes a completion condition that determines when the group as a whole is considered done. Tracking these correctly requires linking child task instances to a shared logical activity so the UI can show aggregate progress (e.g., "3 of 5 approved").

ALTER TABLE task_state ADD COLUMN multi_instance_activity_id VARCHAR(128);
ALTER TABLE task_state ADD COLUMN instance_index INT;
Enter fullscreen mode Exit fullscreen mode
public async Task<MultiInstanceProgress> GetMultiInstanceProgressAsync(string processInstanceId, string activityId)
{
    var tasks = await _db.TaskState
        .Where(t => t.ProcessInstanceId == processInstanceId && t.MultiInstanceActivityId == activityId)
        .ToListAsync();

    return new MultiInstanceProgress
    {
        Total = tasks.Count,
        Completed = tasks.Count(t => t.Status == "COMPLETED"),
        Pending = tasks.Where(t => t.Status != "COMPLETED").Select(t => t.Assignee)
    };
}
Enter fullscreen mode Exit fullscreen mode

5. Identity Link Management (Fine-Grained Roles)

Beyond assignee/candidate-user/candidate-group, Flowable's IdentityLink model supports arbitrary link types such as owner, participant, and watcher, letting you model roles like "notify on completion" without making someone a candidate or assignee. This is managed through the IdentityLink interface, which stores either a userId or groupId per link type.

public async Task AddIdentityLinkAsync(string taskId, string userIdOrGroupId, string type, bool isGroup = false)
{
    var payload = isGroup
        ? new { groupId = userIdOrGroupId, type }
        : new { userId = userIdOrGroupId, type };
    await _httpClient.PostAsJsonAsync($"runtime/tasks/{taskId}/identitylinks", payload);
}

public async Task<List<IdentityLinkDto>> GetIdentityLinksAsync(string taskId)
{
    var response = await _httpClient.GetAsync($"runtime/tasks/{taskId}/identitylinks");
    return await response.Content.ReadFromJsonAsync<List<IdentityLinkDto>>();
}
Enter fullscreen mode Exit fullscreen mode
CREATE TABLE task_identity_links (
    id BIGSERIAL PRIMARY KEY,
    task_id VARCHAR(64) NOT NULL,
    link_type VARCHAR(32) NOT NULL, -- owner, participant, watcher, candidate
    user_id VARCHAR(128),
    group_id VARCHAR(128),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

The Java event bridge should emit an event whenever addCandidateUser, addCandidateGroup, or a watcher link is added, so the .NET read model's task_identity_links table stays consistent with Flowable's runtime state without a separate polling job.

6. Rejecting, Returning, and Cancelling Tasks

Business processes often need "reject/return to previous step" semantics, which Flowable does not model as a first-class action but implements via BPMN constructs like a sequence flow back to a prior activity, or the taskService.complete() call combined with a rejection variable that a gateway inspects downstream. The .NET wrapper should standardize this into an explicit action so client apps don't need process-specific logic.

public async Task RejectTaskAsync(string taskId, string reason)
{
    var variables = new Dictionary<string, object> { { "approved", false }, { "rejectionReason", reason } };
    await CompleteTaskAsync(taskId, variables);
    // Log rejection explicitly for audit/reporting
    await _db.TaskComments.AddAsync(new TaskComment { TaskId = taskId, Message = $"Rejected: {reason}" });
}

public async Task CancelProcessInstanceAsync(string processInstanceId, string reason)
{
    await _httpClient.DeleteAsync($"runtime/process-instances/{processInstanceId}?deleteReason={Uri.EscapeDataString(reason)}");
}
Enter fullscreen mode Exit fullscreen mode

7. User and Group Directory Sync

Flowable's IdentityService manages its own internal User and Group records used for candidate resolution, and REST exposes GET identity/users/{userId} and equivalent group endpoints. In most enterprise setups, .NET's own identity provider (Azure AD, IdentityServer) is the real source of truth, so the wrapper should periodically sync users/groups into Flowable rather than managing identities natively inside the engine.

public async Task SyncUserToFlowableAsync(string userId, string firstName, string lastName, string email)
{
    var payload = new { id = userId, firstName, lastName, email };
    await _httpClient.PostAsJsonAsync("identity/users", payload);
}

public async Task SyncGroupMembershipAsync(string groupId, string userId)
{
    await _httpClient.PutAsync($"identity/groups/{groupId}/members/{userId}", null);
}
Enter fullscreen mode Exit fullscreen mode

This keeps Flowable's candidate resolution accurate without duplicating full identity management logic, while your .NET tracking database independently mirrors group membership for fast local queries when rendering "my group's tasks" views.


Since .NET applications cannot directly attach to Flowable's in-JVM event listeners, the most reliable production pattern is a hybrid architecture: a lightweight Java-side event bridge publishes engine events to a message broker, and a .NET wrapper service consumes these events to maintain its own tracking database as the single source of truth for querying task/process state. This avoids querying the Flowable REST API directly on every read, which is slow and creates tight coupling.

1. High-Level Architecture

The system has four moving parts, each with a distinct responsibility:

  • Flowable Engine (Java): Executes BPMN definitions; the only system that owns process state transitions.
  • Java Event Bridge: A thin Spring component implementing AbstractFlowableEngineEventListener that serializes every relevant event to JSON and publishes it to a broker topic/queue.
  • Message Broker (Kafka or RabbitMQ): Decouples the Java engine from the .NET consumer; guarantees at-least-once delivery so no task transition is lost even if .NET is temporarily down.
  • .NET Tracking Service: Consumes events, upserts them into a dedicated SQL/Postgres database, and exposes this data via its own API/SignalR hub — becoming the wrapper layer that all downstream .NET apps query instead of hitting Flowable REST directly.

This means read-heavy operations (dashboards, task lists, SLA reports) hit your own fast local database, while write operations (claim, complete, delegate) still go through Flowable's REST API since Flowable must remain the transactional authority for process state.

2. Java Event Bridge (Publisher Side)

The bridge listens to the full set of task and process events and forwards them as a normalized envelope, rather than raw Flowable objects, so the .NET side has a stable contract independent of Flowable's internal API version.

@Component
public class FlowableEventBridge extends AbstractFlowableEngineEventListener {

    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    private String toJson(String eventType, TaskInfo task, Execution execution) {
        Map<String, Object> envelope = new HashMap<>();
        envelope.put("eventType", eventType);
        envelope.put("timestamp", Instant.now().toString());
        if (task != null) {
            envelope.put("taskId", task.getId());
            envelope.put("taskName", task.getName());
            envelope.put("assignee", task.getAssignee());
            envelope.put("owner", task.getOwner());
            envelope.put("processInstanceId", task.getProcessInstanceId());
            envelope.put("createTime", task.getCreateTime());
        }
        if (execution != null) {
            envelope.put("processInstanceId", execution.getProcessInstanceId());
            envelope.put("processDefinitionId", execution.getProcessDefinitionId());
        }
        return new Gson().toJson(envelope);
    }

    @Override
    protected void taskCreated(FlowableEngineEntityEvent event) {
        TaskInfo task = (TaskInfo) event.getEntity();
        kafkaTemplate.send("flowable.task.events", task.getId(), toJson("TASK_CREATED", task, null));
    }

    @Override
    protected void taskAssigned(FlowableEngineEntityEvent event) {
        TaskInfo task = (TaskInfo) event.getEntity();
        kafkaTemplate.send("flowable.task.events", task.getId(), toJson("TASK_ASSIGNED", task, null));
    }

    @Override
    protected void taskCompleted(FlowableEngineEntityEvent event) {
        TaskInfo task = (TaskInfo) event.getEntity();
        kafkaTemplate.send("flowable.task.events", task.getId(), toJson("TASK_COMPLETED", task, null));
    }

    @Override
    protected void processCompleted(FlowableEngineEntityEvent event) {
        Execution execution = (Execution) event.getEntity();
        kafkaTemplate.send("flowable.process.events", execution.getProcessInstanceId(),
            toJson("PROCESS_COMPLETED", null, execution));
    }
}
Enter fullscreen mode Exit fullscreen mode

Register additional handlers for taskOwnerChanged, taskDueDateChanged, variableCreated, and variableUpdated to capture custom attribute changes as they happen, rather than polling for them later.

3. Database Schema Design

The tracking database should follow a two-layer model: an append-only event log (audit trail, source of truth for history) and a denormalized read model (current state, optimized for fast queries). This mirrors standard event-sourcing practice and avoids losing history while still giving fast "current status" lookups.

Event Log Table (append-only, immutable)

CREATE TABLE task_events (
    id BIGSERIAL PRIMARY KEY,
    event_id UUID NOT NULL UNIQUE,
    event_type VARCHAR(50) NOT NULL,
    task_id VARCHAR(64),
    process_instance_id VARCHAR(64) NOT NULL,
    process_definition_id VARCHAR(128),
    assignee VARCHAR(128),
    owner VARCHAR(128),
    candidate_group VARCHAR(128),
    action VARCHAR(32),
    payload JSONB NOT NULL,
    event_timestamp TIMESTAMPTZ NOT NULL,
    received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_task_events_process ON task_events (process_instance_id, event_timestamp);
CREATE INDEX idx_task_events_task ON task_events (task_id, event_timestamp);
Enter fullscreen mode Exit fullscreen mode

Read Model Tables (current state, upserted)

CREATE TABLE task_state (
    task_id VARCHAR(64) PRIMARY KEY,
    process_instance_id VARCHAR(64) NOT NULL,
    task_name VARCHAR(256),
    status VARCHAR(32) NOT NULL,       -- CREATED, ASSIGNED, CLAIMED, COMPLETED, CANCELLED
    assignee VARCHAR(128),
    owner VARCHAR(128),
    candidate_group VARCHAR(128),
    create_time TIMESTAMPTZ,
    claim_time TIMESTAMPTZ,
    complete_time TIMESTAMPTZ,
    due_date TIMESTAMPTZ,
    last_action VARCHAR(32),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE process_state (
    process_instance_id VARCHAR(64) PRIMARY KEY,
    process_definition_key VARCHAR(128),
    status VARCHAR(32) NOT NULL,        -- RUNNING, COMPLETED, CANCELLED, SUSPENDED
    start_time TIMESTAMPTZ,
    end_time TIMESTAMPTZ,
    started_by VARCHAR(128),
    business_key VARCHAR(256),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE task_variables (
    id BIGSERIAL PRIMARY KEY,
    task_id VARCHAR(64) NOT NULL REFERENCES task_state(task_id),
    var_name VARCHAR(128) NOT NULL,
    var_value JSONB,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(task_id, var_name)
);
Enter fullscreen mode Exit fullscreen mode

Keeping task_events separate from task_state lets you replay history for auditing or rebuild the read model from scratch if a bug corrupts a projection, without touching the source-of-truth log.

4. .NET Consumer and Projection Logic

The .NET consumer subscribes to the broker topic and applies each event as a projection: it writes to the immutable log first, then upserts the corresponding read-model row. Making the handler idempotent (checking event_id uniqueness) protects against RabbitMQ/Kafka's at-least-once delivery causing duplicate processing.

public class TaskEventConsumer : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly IConnection _connection;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var channel = _connection.CreateModel();
        channel.QueueDeclare("flowable.task.events", durable: true, exclusive: false, autoDelete: false);

        var consumer = new EventingBasicConsumer(channel);
        consumer.Received += async (model, ea) =>
        {
            var json = Encoding.UTF8.GetString(ea.Body.ToArray());
            var envelope = JsonSerializer.Deserialize<TaskEventEnvelope>(json);

            using var scope = _scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<TrackingDbContext>();

            if (await db.TaskEvents.AnyAsync(e => e.EventId == envelope.EventId))
            {
                channel.BasicAck(ea.DeliveryTag, false);
                return; // idempotent skip
            }

            await db.TaskEvents.AddAsync(new TaskEvent
            {
                EventId = envelope.EventId,
                EventType = envelope.EventType,
                TaskId = envelope.TaskId,
                ProcessInstanceId = envelope.ProcessInstanceId,
                Payload = json,
                EventTimestamp = envelope.Timestamp
            });

            await ProjectToReadModelAsync(db, envelope);
            await db.SaveChangesAsync();

            channel.BasicAck(ea.DeliveryTag, false);
        };

        channel.BasicConsume("flowable.task.events", autoAck: false, consumer);
        await Task.Delay(Timeout.Infinite, stoppingToken);
    }

    private async Task ProjectToReadModelAsync(TrackingDbContext db, TaskEventEnvelope e)
    {
        var task = await db.TaskState.FindAsync(e.TaskId) ?? new TaskState { TaskId = e.TaskId };
        task.ProcessInstanceId = e.ProcessInstanceId;

        switch (e.EventType)
        {
            case "TASK_CREATED":
                task.Status = "CREATED";
                task.CreateTime = e.Timestamp;
                break;
            case "TASK_ASSIGNED":
                task.Status = "ASSIGNED";
                task.Assignee = e.Assignee;
                break;
            case "TASK_COMPLETED":
                task.Status = "COMPLETED";
                task.CompleteTime = e.Timestamp;
                break;
        }
        task.UpdatedAt = DateTime.UtcNow;

        db.TaskState.Update(task);
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Pushing Real-Time Updates to Clients

Once the read model is updated, the .NET service broadcasts the change to connected UI clients via SignalR so dashboards update instantly without polling.

public async Task NotifyClientsAsync(TaskState updatedTask)
{
    await _hubContext.Clients.Group(updatedTask.ProcessInstanceId)
        .SendAsync("TaskStatusChanged", new
        {
            updatedTask.TaskId,
            updatedTask.Status,
            updatedTask.Assignee,
            updatedTask.UpdatedAt
        });
}
Enter fullscreen mode Exit fullscreen mode

6. External Worker Pattern for Custom Business Logic

When .NET needs to actively execute business logic as a step in the process (not just observe it), use Flowable's External Worker Job API instead of the event bridge. The .NET worker polls to acquire a lock on a job, executes custom logic, and reports completion — while still logging the action into the same tracking database for consistency with the event-driven flow.

public class ExternalWorkerService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var jobs = await AcquireJobsAsync("dotnet-worker-topic", lockDuration: 30000);
            foreach (var job in jobs)
            {
                try
                {
                    var result = await ExecuteBusinessLogicAsync(job);
                    await CompleteJobAsync(job.Id, result);
                    await LogWorkerActionAsync(job.Id, "COMPLETED", result);
                }
                catch (Exception ex)
                {
                    await FailJobAsync(job.Id, ex.Message);
                    await LogWorkerActionAsync(job.Id, "FAILED", ex.Message);
                }
            }
            await Task.Delay(2000, stoppingToken);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

7. Why This Design Avoids Direct API Dependency Per Call

Because every state change is captured once at the source (Java listener) and projected into the .NET-owned database, all subsequent reads — task lists, SLA dashboards, audit trails, per-user/group views — are served from local SQL queries instead of round-tripping to Flowable's REST API each time. Flowable is only called for two things: performing state-changing actions (claim, complete, delegate) and, if needed, reconciliation jobs that periodically verify the read model hasn't drifted from Flowable's own history tables. This gives you both real-time visibility and resilience against Flowable downtime for read operations.

Top comments (0)