Engineering

Implementing the BPMN Service Task Worker Pattern in Go

Published 6 May 2026  ·  12 min read  ·  By Priostack Engineering

When you model a BPMN 2.0 process that calls an external service — sending an email, charging a credit card, calling a third-party API — you reach for a Service Task. The execution engine needs something to actually run that task, and that something is a job worker. Getting the worker pattern right in Go (Golang) means your workflow engine stays responsive, your jobs are idempotent, and errors feed back into the process where boundary events can handle them gracefully.

This tutorial walks through the complete BPMN service task worker pattern in Go using Priostack's REST API. By the end you will have a production-ready worker that polls for jobs, handles variables, retries on failure, and throws BPMN errors when the business logic cannot continue.

What is a Service Task Worker?

In BPMN 2.0, a Service Task represents work performed by an automated system rather than a human. When the workflow engine reaches a service task, it creates a job and waits. A worker is a separate process that:

  1. Polls the engine for available jobs of a specific type (e.g., send-email).
  2. Locks the job to prevent concurrent processing.
  3. Executes the business logic (call the email API).
  4. Reports success (complete) or failure (fail / throw error) back to the engine.

This asynchronous, polling-based architecture means your workers can be deployed independently, scaled horizontally, and replaced without redeploying the workflow engine. It is the same model used by Zeebe (Camunda 8) and Activiti — Priostack implements the same protocol over REST, making Go workers trivial to write.

Setting Up the Priostack Worker SDK

Priostack exposes a REST job API. You do not need a gRPC dependency or a heavy SDK. A standard net/http client is sufficient. First, sign up at priostack.com/quickstart and get your API key.

Create a new Go module for your worker:

# Create worker project mkdir email-worker && cd email-worker go mod init example.com/email-worker

Define a minimal client struct that wraps the Priostack base URL and API key:

package main import ( "bytes" "context" "encoding/json" "fmt" "log" "net/http" "time" ) type Client struct { BaseURL string APIKey string HTTP *http.Client } func NewClient(baseURL, apiKey string) *Client { return &Client{ BaseURL: baseURL, APIKey: apiKey, HTTP: &http.Client{Timeout: 30 * time.Second}, } } func (c *Client) do(req *http.Request) (*http.Response, error) { req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("Content-Type", "application/json") return c.HTTP.Do(req) }

Now implement the ActivateJobs call. This is a long-poll: the engine holds the request open for up to requestTimeout milliseconds, returning jobs as soon as one is available.

type Job struct { Key int64 `json:"key"` Type string `json:"type"` Variables map[string]interface{} `json:"variables"` Retries int `json:"retries"` WorkerName string `json:"worker"` Deadline int64 `json:"deadline"` } func (c *Client) ActivateJobs(ctx context.Context, jobType string, maxJobs int) ([]Job, error) { body, _ := json.Marshal(map[string]interface{}{ "type": jobType, "maxJobsToActivate": maxJobs, "worker": "go-email-worker", "timeout": 300000, // 5 minute lock "requestTimeout": 20000, // 20 second long-poll }) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/v1/jobs/activate", bytes.NewReader(body)) resp, err := c.do(req) if err != nil { return nil, err } defer resp.Body.Close() var result struct { Jobs []Job `json:"jobs"` } json.NewDecoder(resp.Body).Decode(&result) return result.Jobs, nil }

Handling Variables and Output Mapping

Service tasks exchange data with the process via variables. Input variables are available on job.Variables; output variables are passed when completing the job.

func (c *Client) CompleteJob(ctx context.Context, jobKey int64, outputVars map[string]interface{}) error { body, _ := json.Marshal(map[string]interface{}{ "variables": outputVars, }) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+fmt.Sprintf("/api/v1/jobs/%d/complete", jobKey), bytes.NewReader(body)) resp, err := c.do(req) if err != nil { return err } resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return fmt.Errorf("complete job: unexpected status %d", resp.StatusCode) } return nil }

To read input variables safely, use type assertions with defaults:

// Reading typed variables from a job toEmail, _ := job.Variables["recipientEmail"].(string) subject, _ := job.Variables["emailSubject"].(string) if toEmail == "" { // variable missing — fail the job failJob(ctx, job.Key, "recipientEmail variable is required", job.Retries-1) return }

Error Handling and Retry Logic

Workers distinguish between two types of failure:

func (c *Client) FailJob(ctx context.Context, jobKey int64, reason string, retries int) error { body, _ := json.Marshal(map[string]interface{}{ "retries": retries, "errorMessage": reason, }) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+fmt.Sprintf("/api/v1/jobs/%d/fail", jobKey), bytes.NewReader(body)) resp, err := c.do(req) if err != nil { return err } resp.Body.Close() return nil } func (c *Client) ThrowError(ctx context.Context, jobKey int64, errorCode, msg string) error { body, _ := json.Marshal(map[string]interface{}{ "errorCode": errorCode, "errorMessage": msg, }) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+fmt.Sprintf("/api/v1/jobs/%d/error", jobKey), bytes.NewReader(body)) resp, err := c.do(req) if err != nil { return err } resp.Body.Close() return nil }

Implement exponential back-off when ActivateJobs returns an empty list (engine is idle) or a server error, to avoid hammering the API:

func pollWithBackoff(ctx context.Context, c *Client, jobType string, handler JobHandler) { backoff := 500 * time.Millisecond maxBackoff := 30 * time.Second for { select { case <-ctx.Done(): return default: } jobs, err := c.ActivateJobs(ctx, jobType, 10) if err != nil { log.Printf("activate error: %v — retrying in %s", err, backoff) time.Sleep(backoff) backoff = min(backoff*2, maxBackoff) continue } backoff = 500 * time.Millisecond // reset on success for _, job := range jobs { go handler(ctx, job) } } }

Complete Example: Email Notification Service Task

Here is a full worker that handles an email-notification service task. It reads recipient, subject, and body from process variables, calls a hypothetical email API, and completes the job with a messageSid output variable.

package main import ( "context" "fmt" "log" "os" "os/signal" "syscall" ) type JobHandler func(ctx context.Context, job Job) func handleEmailNotification(client *Client) JobHandler { return func(ctx context.Context, job Job) { to, _ := job.Variables["recipientEmail"].(string) subject, _ := job.Variables["emailSubject"].(string) body, _ := job.Variables["emailBody"].(string) if to == "" { client.FailJob(ctx, job.Key, "recipientEmail is required", 0) return } // Call your email provider here sid, err := sendEmail(to, subject, body) if err != nil { if isBounce(err) { // Domain-level error: trigger boundary event client.ThrowError(ctx, job.Key, "EMAIL_BOUNCE", err.Error()) } else { // Transient: retry up to 3 times retries := job.Retries - 1 client.FailJob(ctx, job.Key, fmt.Sprintf("send failed: %v", err), retries) } return } // Success: write output variables back to the process client.CompleteJob(ctx, job.Key, map[string]interface{}{ "messageSid": sid, "emailSentAt": nowISO(), }) log.Printf("job %d completed: email sent to %s (sid=%s)", job.Key, to, sid) } } func main() { client := NewClient( os.Getenv("PRIOSTACK_URL"), os.Getenv("PRIOSTACK_API_KEY"), ) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() log.Println("Email worker started — polling for email-notification jobs") pollWithBackoff(ctx, client, "email-notification", handleEmailNotification(client)) log.Println("Worker stopped") }

Set the environment variables and run:

PRIOSTACK_URL=https://api.priostack.com \ PRIOSTACK_API_KEY=your-api-key \ go run .

Conclusion

The BPMN service task worker pattern in Go is straightforward: poll, lock, execute, complete. The key design decisions are:

Priostack's API keeps things simple — no gRPC, no heavy client library, just HTTP. You can have your first worker running in minutes.

Ready to run your first BPMN service task?

Get a free API key, deploy your BPMN, and have a worker polling in under 5 minutes.

Quickstart guide API docs

Frequently Asked Questions

What is a BPMN service task worker?

A BPMN service task worker is a long-running process that polls a workflow engine for pending jobs of a specific type, executes the business logic, and reports the result back to the engine. The worker pattern decouples your business logic from the workflow orchestration layer.

How do I implement a BPMN worker in Go?

With Priostack, poll for activated jobs via POST /api/v1/jobs/activate, process each job in a goroutine, and complete them via POST /api/v1/jobs/{key}/complete. See the full code example above.

What is the difference between a job worker and a service task?

A service task is the BPMN modelling concept — a task in your process diagram that calls an external service. A job worker is the runtime implementation — the Go process that subscribes to that service task type and executes it.

How does error handling work in BPMN service task workers?

Workers can report job failure via POST /api/v1/jobs/{key}/fail with a decremented retries count. When retries reach zero the engine creates an incident. For domain errors, use POST /api/v1/jobs/{key}/error to throw a BPMN error caught by boundary events.

Can I use Priostack as a Camunda/Zeebe worker replacement?

Yes. Priostack's job worker API is protocol-compatible with the Zeebe job worker model. Point your existing worker endpoint at Priostack and it works without code changes. See also: migrating from Camunda.

Related: Enterprise integration patterns without a message broker  ·  Two-layer BPMN architecture  ·  Docs: Job Workers