temporal-entity

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 7, 2026 License: MIT

README

temporal-entity

Manage long-lived resources as durable Temporal workflows.

temporal-entity is a library chassis for the Entity Lifecycle Pattern (aka Entity Workflow) on top of the vanilla Temporal Go SDK: one long-running workflow per resource, acting as the durable control-plane record for that resource. You write ordinary Temporal code — activities, child workflows, timers. The chassis owns the pattern.

h := entclient.Bind(databaseKind, temporalClient, "task-queue")

entclient.ExecWithStart(ctx, h, "db-42", spec, Scale{Replicas: 5}) // create-or-attach + command
st, _ := h.Describe(ctx, "db-42")                                  // durable status, any time
h.Delete(ctx, "db-42")                                             // graceful teardown

Why

If a one-shot workflow is a verb ("provision this database"), an entity workflow is a noun ("this database"). The entity workflow is the resource's control-plane record: it holds the desired spec, tracks current state, accepts commands, serializes side effects, reports status, and heals drift — durably, across process restarts, for months or years.

Temporal's platform control-plane article describes the pattern in detail. Implementing it correctly by hand means getting all of this right, every time, for every resource type:

Concern What goes wrong without it
Deterministic workflow IDs duplicate entities for one resource
Command queue + single main loop racing side effects
Update validators garbage requests polluting Event History
Request-id deduplication a retried command executing twice
Continue-as-New discipline unbounded history → slow replay → forced termination
Deletion as a lifecycle transition orphaned resources, callers hanging forever
Saga compensation half-applied multi-step operations

This library implements each of those once, as a chassis, and leaves you exactly the parts that are yours: the resource types, the command handlers, and the domain knowledge (what "ready" means, what counts as drift).

No code generation anywhere. Commands, queries, and resource specs are your own typed Go structs.

Install

go get github.com/graphene-ci/temporal-entity

Requires Go ≥ 1.24 (generic type aliases) and any Temporal server the Go SDK supports.

Quickstart

1. Declare commands as types

A command is a struct: its fields are the parameters, its methods carry the name, the response type, and (optionally) validation. No descriptor variables, no name strings anywhere else.

import entity "github.com/graphene-ci/temporal-entity/pkg/entity"

type ScaleRes struct{ OK bool }

type Scale struct{ Replicas int }

func (Scale) Name() entity.CommandName { return "scale" }
func (Scale) Result() ScaleRes         { return ScaleRes{} } // phantom: binds the response type, never called
func (s Scale) Validate() error {                            // optional; runs BEFORE Event History
    if s.Replicas < 1 {
        return errors.New("replicas must be >= 1")
    }
    return nil
}

Result() is the Go encoding of an associated type: it lets the compiler infer the response type at every call site, so calls need zero explicit type parameters.

2. Define the kind

A kind is one resource type: its desired-spec type, its state type, and its lifecycle handlers. Handlers contain ordinary Temporal code.

import "github.com/graphene-ci/temporal-entity/pkg/entdefine"

type DBSpec struct{ Tier string; Replicas int }
type DBState struct{ Endpoint string; Replicas int }

db := entdefine.New[DBSpec, DBState]("database",
    // creation: bring the resource to life from its desired spec
    entdefine.WithInit[DBSpec, DBState](func(ctx workflow.Context, spec DBSpec) (DBState, error) {
        var st DBState
        err := workflow.ExecuteActivity(actx(ctx), acts.Provision, spec).Get(ctx, &st)
        return st, err
    }),
    // deletion: tear the resource down after the entity is drained
    entdefine.WithFinalize[DBSpec, DBState](func(ctx workflow.Context, st *DBState) error {
        return workflow.ExecuteActivity(actx(ctx), acts.Teardown, st.Endpoint).Get(ctx, nil)
    }),
    // periodic health/drift tick, serialized with commands
    entdefine.WithReconcileEvery[DBSpec, DBState](30*time.Second,
        func(ctx workflow.Context, ec *entdefine.Ctx[DBSpec, DBState]) error {
            return workflow.ExecuteActivity(actx(ctx), acts.CheckHealth, ec.State().Endpoint).Get(ctx, nil)
        }),
)
3. Handle commands

Everything is inferred from the handler's signature — the command type supplies the name and the response type:

entdefine.Handle(db, func(ctx workflow.Context, ec *entdefine.Ctx[DBSpec, DBState], cmd Scale) (ScaleRes, error) {
    if err := workflow.ExecuteActivity(actx(ctx), acts.Resize, cmd.Replicas).Get(ctx, nil); err != nil {
        return ScaleRes{}, err // returned to the CALLER; the entity keeps living
    }
    ec.State().Replicas = cmd.Replicas
    return ScaleRes{OK: true}, nil
})

Handlers always run on the entity's main loop — one command at a time, in order, never concurrently with another command or a reconcile tick. That is the chassis's core guarantee: side effects are serialized by construction.

4. Register with a worker
w := worker.New(c, "task-queue", worker.Options{})
if err := db.Register(w); err != nil {
    log.Fatal(err) // ALL registration problems surface here as one error list — no panics
}
5. Drive it from anywhere with a Temporal client
import "github.com/graphene-ci/temporal-entity/pkg/entclient"

h := entclient.Bind(db, c, "task-queue")

// create-or-attach + first command, atomically (update-with-start):
res, err := entclient.ExecWithStart(ctx, h, "db-42", DBSpec{Tier: "m", Replicas: 3}, Scale{Replicas: 3})

// tracked command against a running entity; waits for the result:
res, err = entclient.Exec(ctx, h, "db-42", Scale{Replicas: 5})

// built-in status query (works even after the entity completed):
st, err := h.Describe(ctx, "db-42")

// graceful teardown: drain -> finalize -> complete:
err = h.Delete(ctx, "db-42")

Workflow IDs are deterministic — "database/db-42" — so at most one open entity exists per resource, and callers reconnect instead of spawning duplicates.

Guide

Queries

Queries are types too, with the same contract shape. Handlers receive a read-only, by-value Snapshot — mutating entity state from a query is a replay-determinism bug, and the snapshot makes it impossible by construction:

type Health struct{}
type HealthRes struct{ Ready bool }

func (Health) Name() entity.QueryName { return "health" }
func (Health) Result() HealthRes      { return HealthRes{} }

entdefine.HandleQuery(db, func(s entity.Snapshot[DBSpec, DBState], _ Health) (HealthRes, error) {
    return HealthRes{Ready: s.State.Replicas == s.Spec.Replicas}, nil
})

h, err := entclient.Read(ctx, h, "db-42", Health{})
Validation: three layers, all optional, all combined
  1. Validate() error on the command type — request-only checks, no type parameters. The common case.
  2. ValidateWith(s entity.Snapshot[Spec, State]) error on the command type — checks that need the entity's current state.
  3. Kind-level via the fluent tail of Handle — for validation that needs registration-time dependencies a command type cannot capture:
entdefine.Handle(db, applyHandler).Validate(func(s entity.Snapshot[DBSpec, DBState], cmd Apply) error {
    return kindConfig.validateSpec(cmd.Spec)
})

All validators run before anything is committed to Event History: rejected requests leave no trace and cost nothing. They must be pure and deterministic.

Idempotent retries: request ids

Every command carries an application-level request id (auto-generated by Exec). To make an operation idempotent end-to-end, supply your own and retry with the same id — the entity serves the cached result instead of re-executing, even across Continue-as-New:

reqID := entity.RequestID(myIdempotencyKey)
res, err := entclient.ExecWithRequestID(ctx, h, "db-42", reqID, Scale{Replicas: 5})
Multi-step operations: the saga helper

For operations that must undo partial progress on failure:

saga := entdefine.NewSaga(ctx).
    Step("resize",
        func(c workflow.Context) error { return exec(c, acts.Resize, n) },
        func(c workflow.Context) error { return exec(c, acts.Resize, prev) }, // compensation
    ).
    Step("update-dns",
        func(c workflow.Context) error { return exec(c, acts.UpdateDNS, n) },
        nil, // no undo needed
    )
if err := saga.Run(); err != nil {
    return Res{}, err // completed steps were compensated in reverse order
}

Run returns the error to the caller instead of failing the workflow — the entity keeps living. Forward and compensating actions run as activities and must be idempotent (safe to retry).

Child workflows and activities

Nothing is wrapped: inside any handler you use workflow.ExecuteActivity, workflow.ExecuteChildWorkflow, timers, selectors — plain Temporal. Rule of thumb from the pattern: activities for single side effects, entity command handlers for multi-step serialized operations, child workflows for independent work that needs its own event history or parallelism.

The lifecycle
creating ──> ready ──(Delete signal)──> deleting ──> deleted
    │                                       │
    └─> create_failed                       └─> delete_failed
  • creating: WithInit runs. Commands sent this early queue up.
  • ready: the main loop serves commands and reconcile ticks.
  • deleting: pending commands are failed with an explicit error, new commands are rejected by the validator layer, in-flight handlers drain, then WithFinalize runs.
  • deleted: the workflow completes. Describe and queries still work (Temporal serves queries on closed workflows).
Continue-as-New

Temporal workflows must not grow history forever. The chassis handles this invisibly: when the server suggests it (GetContinueAsNewSuggested), the main loop waits for the queue to empty and all handlers to finish, then continues-as-new carrying only the compact envelope — phase, spec, state, deletion flag, pending queue, dedup cache. Callers never notice; the workflow ID stays the same.

Keep the carried state compact: store large artifacts (manifests, plans, logs) in external storage and keep references in state.

entdefine.WithForceCANEveryNCommands(n) forces the transition every n commands — a test-only knob for exercising the path quickly.

Building a domain library on top

The two type parameters [Spec, State] are the chassis's irreducible minimum, but a domain library can hide them with generic type aliases (Go ≥ 1.24) when its state type is a function of the resource type:

type Ctx[T any]  = entdefine.Ctx[T, State[T]]
type Snap[T any] = entity.Snapshot[T, State[T]]

examples/reconciler shows the full shape: a library that manages typed resources the way a Kubernetes controller would — apply the desired spec, poll for readiness, heal drift on the reconcile tick, tear down on delete — with the entity workflow as the control loop. Its users write:

deployments := k8slib.NewKind[Deployment]("apps/v1/Deployment",
    k8slib.WithReady[Deployment](func(live Deployment) bool {
        return live.ReadyReplicas >= live.Replicas
    }),
    k8slib.WithDrifted[Deployment](func(desired, live Deployment) bool {
        return desired.Image != live.Image
    }),
)

The user brings the resource type and the two pieces of domain knowledge generic machinery cannot have: what "ready" means and what counts as drift.

Package layout

Package Role
pkg/entity shared vocabulary: id types (ResourceID, RequestID, ...), the Command[Res]/Query[Res] contracts, Phase, Snapshot, DescribeOut
pkg/entdefine the kind author's side: Definition, options, Handle/HandleQuery, Ctx, the main loop, Saga
pkg/entclient the caller's side: Bind, Exec, ExecWithStart, ExecWithRequestID, Read, Describe, Delete
internal/wire envelope and update wire shapes, hidden from both sides

Command/query types reference only pkg/entity — declaring commands pulls in neither the defining nor the calling side.

Development

make configure   # install pinned tools into ./bin (nothing global)
make lint        # golangci-lint, must be 0 issues
make test        # unit tests + integration suite on a real dev server

The integration suite (integration/) starts a Temporal dev server from ./bin/temporal (version pinned in the Makefile) and verifies every property of the pattern end-to-end: update-with-start, validator rejection, drift healing, out-of-band recreation, request-id dedup, Continue-as-New with the dedup cache surviving the boundary, a saga command with a child workflow, and the deletion lifecycle.

Notes and caveats

  • Never put secrets or PII in resource ids — they end up in workflow IDs and Event History. Pass sensitive payloads via Temporal Data Converters.
  • Commands are Temporal Updates under the hood: the server caps in-flight updates (~10 per workflow); Exec waits for completion, so callers are naturally throttled.
  • Query and validator functions must be pure — no side effects, no workflow APIs, no blocking.
  • Value-type commands: implement the contract methods on value receivers; the chassis instantiates zero values to read names at registration.

Directories

Path Synopsis
examples
reconciler command
Command example is the USER side of the PoC: a typed resource managed as an Entity Workflow via k8slib, exercising every property from the article — update-with-start, validators, serialized command queue, drift-healing reconcile ticks, request-id dedup across Continue-as-New, a user-defined saga command with a child workflow, a user-defined typed query, and deletion as a lifecycle transition.
Command example is the USER side of the PoC: a typed resource managed as an Entity Workflow via k8slib, exercising every property from the article — update-with-start, validators, serialized command queue, drift-healing reconcile ticks, request-id dedup across Continue-as-New, a user-defined saga command with a child workflow, a user-defined typed query, and deletion as a lifecycle transition.
reconciler/k8slib
Package k8slib manages Kubernetes-style resources as Temporal Entity Workflows: one durable workflow per resource, holding its desired spec, applying it to the cluster, watching for drift on a periodic tick, and healing it — a reconciler whose control loop is an entity workflow.
Package k8slib manages Kubernetes-style resources as Temporal Entity Workflows: one durable workflow per resource, holding its desired spec, applying it to the cluster, watching for drift on a periodic tick, and healing it — a reconciler whose control loop is an entity workflow.
reconciler/k8slib/fake
Package fake is an in-memory Cluster for verifying the entity chassis without a real Kubernetes.
Package fake is an in-memory Cluster for verifying the entity chassis without a real Kubernetes.
internal
wire
Package wire holds the on-the-wire and carried-state contract shared by the definition side (entity/entdefine) and the caller side (entity/entclient).
Package wire holds the on-the-wire and carried-state contract shared by the definition side (entity/entdefine) and the caller side (entity/entclient).
pkg
entclient
Package entclient is the CALLER side of an entity kind: bind a definition to a Temporal client, then drive the entity — Exec commands, Read queries, Describe, Delete.
Package entclient is the CALLER side of an entity kind: bind a definition to a Temporal client, then drive the entity — Exec commands, Read queries, Describe, Delete.
entdefine
Package entdefine is the defining side of the Temporal Entity Lifecycle Pattern chassis (aka Entity Workflow): one long-running workflow per resource, acting as the durable control-plane record for it.
Package entdefine is the defining side of the Temporal Entity Lifecycle Pattern chassis (aka Entity Workflow): one long-running workflow per resource, acting as the durable control-plane record for it.
entity
Package entity is the shared vocabulary of the Entity Lifecycle Pattern chassis: identifier types, the Command/Query contracts, and the read-only state views seen by both the defining and calling sides.
Package entity is the shared vocabulary of the Entity Lifecycle Pattern chassis: identifier types, the Command/Query contracts, and the read-only state views seen by both the defining and calling sides.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL