Your First Temporal Workflow in Go#
This article establishes the patterns used throughout the Temporal series: dependency injection for testable activities, idempotency for safe retries, and a clean worker binary. Every subsequent article builds on these foundations.
All code lives in the companion repo at github.com/statherm/temporal-examples. For background, see Introduction to Temporal and Namespaces and Task Queues.
Project Structure#
The companion repo organizes code by domain:
temporal-examples/
cmd/worker/main.go # Worker binary
cmd/starter/main.go # Workflow starter CLI
internal/container/
activities.go # Activity implementations with DI
workflow.go # Workflow definitions
types.go # Interfaces and types
MakefileWorkflows and Activities#
A workflow is a deterministic function that orchestrates work. It takes workflow.Context, must not perform side effects, and dispatches work through activities. Activities use standard context.Context and perform real I/O:
func HelloWorkflow(ctx workflow.Context, name string) (string, error) {
ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Second,
})
var result string
err := workflow.ExecuteActivity(ctx, Greet, name).Get(ctx, &result)
return result, err
}
func Greet(ctx context.Context, name string) (string, error) {
return fmt.Sprintf("Hello, %s!", name), nil
}They are separate because: (1) activity results are recorded and replayed, preventing duplicate side effects; (2) failed activities retry independently; (3) each has its own timeout and retry policy.
Dependency Injection Pattern#
If your activity calls a Docker API, unit tests should not need a running daemon. Define an interface, hold it in a struct, and inject it through a constructor:
// internal/container/types.go
type ContainerClient interface {
Inspect(ctx context.Context, id string) (ContainerInfo, error)
Stop(ctx context.Context, id string) error
Remove(ctx context.Context, id string) error
}
// internal/container/activities.go
type ContainerActivities struct {
client ContainerClient
}
func NewContainerActivities(c ContainerClient) *ContainerActivities {
return &ContainerActivities{client: c}
}
func (a *ContainerActivities) StopContainer(ctx context.Context, req StopRequest) error {
info, err := a.client.Inspect(ctx, req.ContainerID)
if err != nil {
return fmt.Errorf("inspect container %s: %w", req.ContainerID, err)
}
if info.State == "exited" {
return nil // already stopped -- idempotent
}
return a.client.Stop(ctx, req.ContainerID)
}
func (a *ContainerActivities) RemoveContainer(ctx context.Context, id string) error {
_, err := a.client.Inspect(ctx, id)
if err != nil {
return nil // does not exist -- treat as success
}
return a.client.Remove(ctx, id)
}This gives you testability (mock ContainerClient), swappable implementations (Docker, Podman, cloud API), and clean separation from runtime-specific libraries.
Idempotency Pattern#
Temporal retries failed activities. If an activity succeeds but the worker crashes before reporting, Temporal retries it. Activities must handle being called multiple times.
The check-before-act pattern is shown in StopContainer above – check if the container is already stopped before acting. For create operations, use idempotency keys generated from the workflow (not inside the activity):
func ProvisionWorkflow(ctx workflow.Context, spec ResourceSpec) error {
key := fmt.Sprintf("%s-create", workflow.GetInfo(ctx).WorkflowExecution.ID)
// ... pass key to activity so retries always use the same key
}The Worker Binary#
The worker wires everything together: Temporal client, real dependencies, activity registration, and polling.
// cmd/worker/main.go
func main() {
c, err := client.Dial(client.Options{
HostPort: getEnv("TEMPORAL_HOST", "localhost:7233"),
Namespace: getEnv("TEMPORAL_NAMESPACE", "default"),
})
if err != nil {
log.Fatalln("Unable to create client:", err)
}
defer c.Close()
dockerClient, err := container.NewDockerClient()
if err != nil {
log.Fatalln("Unable to create Docker client:", err)
}
containerActivities := container.NewContainerActivities(dockerClient)
w := worker.New(c, "container-ops", worker.Options{
MaxConcurrentActivityExecutionSize: 20,
})
w.RegisterWorkflow(container.CleanupWorkflow)
w.RegisterActivity(containerActivities)
log.Println("Starting worker on container-ops")
err = w.Run(worker.InterruptCh())
if err != nil {
log.Fatalln("Worker failed:", err)
}
}w.RegisterActivity(containerActivities) registers every exported method on the struct as an activity. worker.InterruptCh() handles SIGINT/SIGTERM for graceful shutdown.
The Cleanup Workflow#
A real workflow using both patterns – stopping and removing containers with error handling:
func CleanupWorkflow(ctx workflow.Context, req CleanupRequest) (CleanupResult, error) {
ao := workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumAttempts: 3,
},
}
ctx = workflow.WithActivityOptions(ctx, ao)
result := CleanupResult{}
var activities *ContainerActivities
for _, id := range req.ContainerIDs {
err := workflow.ExecuteActivity(ctx, activities.StopContainer, StopRequest{
ContainerID: id, Reason: "cleanup",
}).Get(ctx, nil)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("stop %s: %v", id, err))
continue
}
result.Stopped++
err = workflow.ExecuteActivity(ctx, activities.RemoveContainer, id).Get(ctx, nil)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("remove %s: %v", id, err))
} else {
result.Removed++
}
}
return result, nil
}The nil activities variable is a Go SDK convention – the workflow uses method references to identify activity types. The actual struct with injected dependencies runs on the worker.
Starting a Workflow#
With the CLI:
temporal workflow start \
--task-queue container-ops \
--type CleanupWorkflow \
--workflow-id cleanup-001 \
--input '{"ContainerIDs": ["abc123", "def456"], "Force": false}'
temporal workflow show --workflow-id cleanup-001With the Go SDK:
we, err := c.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{
ID: "cleanup-001",
TaskQueue: "container-ops",
}, container.CleanupWorkflow, req)
if err != nil {
log.Fatalln("Unable to start workflow:", err)
}
var result container.CleanupResult
err = we.Get(context.Background(), &result)The workflow ID must be unique among active workflows in the namespace. Use a meaningful business key (order ID, job ID) so you can look up workflows directly.
The Complete Flow#
make temporal-up # Start Temporal on minikube
kubectl port-forward -n temporal svc/temporal-frontend 7233:7233 &
make run-worker # Build and start worker (foreground)
make start-cleanup # In another terminal, start workflowThe worker connects, registers on container-ops, and polls. The starter creates a workflow execution. Temporal dispatches tasks to the worker. Each activity result is recorded in history. If the worker crashes and restarts, Temporal replays the workflow and skips completed activities.
What’s Next#
- Multi-stage workflows: Branching logic, parallel execution with
workflow.Go, error compensation - Signals and queries: Send data to running workflows and read state without waiting
- Testing: Unit test workflows with mock activities – see Temporal Workflow Testing