Workerkit is a transport-agnostic Go runtime for domain workers: long-running loops, pollers, subscribers, command-driven workers, and other worker-oriented service components.
It gives ordinary Go workers a real production shell: lifecycle control, readiness, graceful shutdown, retries, jitter, concurrency limits, failure policy, panic handling, status inspection, and structured observability — without turning your service into a framework.
If your service owns domain workers that actually matter, Workerkit gives them the same kind of operational story HTTP servers already expect.
Worker-oriented services deserve the same kind of coherent runtime story that HTTP services already expect.
The hard part is not starting a goroutine. The hard part is everything that appears after the goroutine matters: lifecycle, readiness, deploy drain, shutdown deadlines, retry policy, failure visibility, status inspection, command routing, concurrency limits, panic recovery, and useful telemetry.
Those concerns are not domain logic, but they show up in every production service that owns workers. Without a runtime, they tend to spread across channels, health flags, admin handlers, retry loops, shutdown hooks, and disconnected logs.
Workerkit pulls that operational layer into one reusable runtime. Applications register workers, define the policies that matter, and keep the worker code focused on domain behavior.
A Workerkit worker is still normal Go code. It can run a long-lived loop, watch external systems, consume from a broker, poll an API, maintain in-memory state, or expose domain-specific commands. Workerkit does not decide what the worker does. It gives the worker a predictable operational envelope.
Workerkit also stands next to Opskit and Servekit instead of reinventing an operations registry or HTTP service layer. In the composed Kit Series path, a Workerkit runtime is one Opskit component. Servekit consumes the Opskit registry for /readyz and generic read-only admin component routes. Workerkit stays transport-neutral; Servekit keeps owning the HTTP baseline.
With one runtime, Workerkit gives you:
- explicit worker registration
- startup, drain, and graceful shutdown
- aggregate runtime status and per-worker snapshots
- readiness reporting and readiness aggregation
- worker-owned command registration and direct dispatch
- bounded retry with backoff and jitter
- runtime-wide and worker-local concurrency limits
- panic and failure policy
- structured observer hooks
- optional
slogand OpenTelemetry adapters - Opskit component, readiness, and inspection support
- periodic execution of Opskit check and check-group contracts
- generic execution of Opskit command handlers
- optional Servekit-backed Workerkit command and lifecycle routes
This is the operational layer teams rebuild around serious worker components. Workerkit makes it the baseline instead of the afterthought.
Workerkit is not a workflow engine, job queue, scheduler, or durable orchestration system.
It does not provide durable workflow state, queue persistence, distributed leasing, task assignment, or cross-service coordination. It does not replace brokers, databases, schedulers, or orchestrators. It does not own your domain model.
Workerkit runtime state is process-local. In Kubernetes, that means pod-local.
/readyz, Opskit admin component views, Workerkit inspection, command
dispatch, and lifecycle controls describe or affect only the runtime in the
process that handles the request. Workerkit does not provide deployment-wide
worker orchestration, distributed locking, leader election, replica
coordination, or fleet-wide command broadcast.
It is also not an application framework. You still write normal Go workers, your own business loops, your own side effects, and your own command contracts. Workerkit is the runtime and control surface around those workers, not the application itself.
And Workerkit is not fundamentally tied to HTTP. The core runtime is transport-agnostic. Commands, readiness, lifecycle, failure handling, status, and Opskit inspection all exist as ordinary Go concepts first. If you want the Kit Series HTTP operations path, register the runtime in an Opskit registry and pass that registry to Servekit. If you want Workerkit-specific HTTP command or lifecycle routes, the optional opshttp package can mount those into Servekit.
Workerkit is a good fit when:
- your service runs domain workers, long-lived loops, pollers, subscribers, schedulers, or command-driven workers that need explicit operational control
- you want one runtime to own lifecycle, readiness, shutdown, status, retries, failure handling, concurrency limits, and observability
- your workers own business logic, but you want a consistent production shell around them
- some workers expose operational or domain commands like
index/rebuild,cache/refresh,snapshot/prune, orqueue/drain - you want a transport-agnostic worker runtime with the option to add an HTTP operations surface when HTTP is useful
- you want production-oriented defaults without adopting a full framework
Workerkit is probably not a fit when:
- you need a durable workflow engine, queue system, distributed lock manager, or fleet-wide orchestrator
- you want built-in persistence for workflow state, retries across restarts, or task coordination across services
- you want the runtime to understand and enforce your business domain instead of leaving that logic inside your workers
- your service already has a mature worker runtime and Workerkit would mostly duplicate it
- you only need a tiny helper around one short-lived goroutine, not a managed runtime
go get github.com/jaredjakacky/workerkitimport workerkit "github.com/jaredjakacky/workerkit"package main
import (
"context"
"fmt"
"log"
"time"
workerkit "github.com/jaredjakacky/workerkit"
)
type printerWorker struct{}
func (printerWorker) Start(ctx context.Context) error {
fmt.Println("worker started")
return nil
}
func (printerWorker) Stop(ctx context.Context) error {
fmt.Println("worker stopped")
return nil
}
func main() {
ctx := context.Background()
runtime, err := workerkit.New(workerkit.Identity{Name: "quickstart"})
if err != nil {
log.Fatal(err)
}
err = runtime.Register(workerkit.WorkerSpec{
Name: "printer",
Description: "prints worker-owned output",
Worker: printerWorker{},
})
if err != nil {
log.Fatal(err)
}
if err := runtime.StartAll(ctx); err != nil {
log.Fatal(err)
}
status := runtime.RuntimeStatus()
fmt.Printf("runtime=%s state=%s ready=%t workers=%d\n",
status.Name, status.State, status.Ready, status.Workers)
for _, worker := range runtime.Workers() {
fmt.Printf("worker=%s state=%s ready=%t\n",
worker.QualifiedName, worker.Status.State, worker.Status.Ready)
}
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := runtime.Shutdown(shutdownCtx); err != nil {
log.Fatal(err)
}
}That one runtime already gives you the operational pieces that usually get rebuilt around production workers:
- explicit worker registration
- managed startup and graceful shutdown
- aggregate runtime status and per-worker snapshots
- readiness aggregation
- drain-before-stop behavior
- production-oriented panic and failure handling
- extension points for commands, retry, concurrency limits, and observers
In practice, you get a real worker runtime without hand-building lifecycle bookkeeping, readiness flags, shutdown ordering, status inspection, command admission, and failure reporting yourself.
Workerkit is deliberately built around one runtime boundary and ordinary Go workers.
Runtime represents one service boundary. It owns worker registration, lifecycle control, readiness aggregation, status snapshots, worker-owned command dispatch, retry execution, concurrency limits, failure policy, and observer callbacks.
Most services should start with one runtime.
Worker is the lifecycle contract managed by the runtime:
type Worker interface {
Start(context.Context) error
Stop(context.Context) error
}The worker owns domain behavior: loops, input sources, side effects, persistence, broker clients, caches, indexes, and business rules. Workerkit owns how the worker starts, stops, reports readiness, accepts commands, records failure, and exposes status.
Use NewLoopWorker when the worker is primarily a long-running loop that should stop when its context is canceled.
WorkerRuntimeFromContext gives managed worker code a worker-scoped handle for runtime signals:
SetReadySetAcceptingWorkReportFailureStatus
That lets a worker report warmup, pause command admission, or record asynchronous background failure without receiving full runtime authority.
Commands are worker-owned domain operations. They are not lifecycle controls, and they are not inherently HTTP.
Register commands with WithCommand or WithCommandSpec, discover them with Runtime.Commands, and execute them directly with Runtime.Dispatch. Workerkit routes and observes commands, but it does not interpret payloads. The worker owns the command contract.
Workerkit rests on three choices:
- It keeps worker code ordinary Go.
- It gives worker-oriented service components a coherent operational envelope.
- It keeps HTTP optional and outside the core runtime.
That is why the package can stay small while still feeling production-ready from the first constructor call.
Workerkit has a short normal path, but it is not limited to startup and shutdown. Advanced hooks include:
- readiness contribution policy per worker
- runtime-wide and worker-local command concurrency limits
- bounded retry with backoff, jitter, and retry predicates
- lifecycle and command attempt timeouts
- panic recovery or crash policy
- isolated, unready, or failed aggregate runtime failure policy
- worker-owned command discovery and dispatch
- Opskit checker and check-group execution through managed workers
- Opskit command-handler execution through
CommandFromOpskit - structured
slogobserver support - OpenTelemetry observer support
- Opskit-backed readiness and generic admin inspection through Servekit
- opt-in HTTP command dispatch and privileged lifecycle controls
- managed Servekit service composition with
servekitservice.NewManaged
The advanced path is documented in docs/advanced.md, with policy details in docs/policy.md and Servekit composition in docs/composition.md.
Workerkit, Opskit, and Servekit can be used independently. The preferred composed path is:
- Opskit defines passive component metadata/read models plus explicit check and command execution hooks. Opskit does not schedule or invoke those hooks.
- Workerkit runtime implements Opskit component, readiness, and inspection contracts.
- Workerkit executes active Opskit work through
NewCheckLoop,NewCheckGroupLoop, andCommandFromOpskit. - The application registers the runtime in an Opskit registry.
- Servekit consumes that registry with
servekit.WithOps(...). - Servekit presents
/readyzand generic Opskit admin routes.
Servekit owns the HTTP service baseline: server construction, middleware, authentication, readiness endpoints, request policy, endpoint timeouts, response handling, and service lifecycle. Workerkit owns worker runtime semantics: lifecycle, readiness, status, command dispatch, admission, failure policy, and telemetry.
ops := opskit.NewRegistry()
runtime, err := workerkit.New(workerkit.Identity{Name: "workers"})
if err != nil {
return err
}
ops.MustRegister(runtime, opskit.Required())
server := servekit.New(
servekit.WithOps(ops, servekit.WithOpsAdmin()),
)This gives Servekit a pod-local view of the Workerkit runtime. It reports only the runtime state inside this process; it is not a fleet-wide worker registry or distributed control plane.
Active Opskit contracts enter the same Workerkit execution model as native work:
if err := runtime.Register(workerkit.WorkerSpec{
Name: "dependency_checks",
Worker: workerkit.NewCheckLoop(dependency),
}); err != nil {
return err
}
if err := runtime.Register(commandWorker,
workerkit.WithCommandSpec(
workerkit.CommandFromOpskit(commandDescriptor, component),
),
); err != nil {
return err
}NewCheckLoop and NewCheckGroupLoop return normal Workerkit workers. Workerkit owns their interval, jitter, cooperative timeout, cancellation, panic recovery, and readiness/failure integration. A checker cannot be forcibly interrupted, but a result returned after its deadline is not applied to readiness. CommandFromOpskit returns a normal command spec, so dispatch keeps Workerkit admission, timeout, retry, concurrency, panic, observation, and lifecycle behavior.
opshttp is an optional Workerkit-specific HTTP control surface. It is useful when operators specifically need Workerkit command dispatch or privileged lifecycle controls; it is not the primary read-only/readiness composition path. Mutating routes are disabled unless explicitly enabled.
opshttp.Mount adds Workerkit-shaped read-only inspection by default. Command
dispatch and lifecycle controls require explicit options. Even read-only routes
expose operational state, so protect the entire mounted surface; use stricter
Servekit endpoint policy for mutating routes. HTTP controls are pod-local, and
stop routes do not wait for commands already in flight.
The standalone opshttp.ReadinessCheck(runtime) adapter remains available only for standalone Servekit services that do not use an Opskit registry.
See Composition with Opskit and Servekit and the optional
opshttp examples for route inventory, endpoint policy, and error mappings.
- Getting Started: build the smallest useful Workerkit runtime
- Usage Guide: normal runtime, worker, command, status, and shutdown path
- Lifecycle and Readiness: startup, readiness, drain, stop, shutdown, and failure reporting
- Commands: worker-owned domain commands without tying them to HTTP
- Policy Guide: retry, backoff, jitter, concurrency, readiness, and failure policy
- Observability: core runtime observer events, structured logs, and OpenTelemetry
- Composition with Opskit and Servekit: Opskit registry integration,
servekitservice,opshttp, and the Kit Series boundary - Examples Guide: guided walkthrough of the runnable examples
- Advanced Guide: advanced composition and customization patterns
- API Map: human-friendly map of the exported surface
- Examples Directory: short index of the runnable example programs
Runnable programs live in examples/, which includes a guided tour of the example set.
Recommended reading order:
examples/basicexamples/loop-workerexamples/readinessexamples/commandsexamples/opskit-checksexamples/opskit-commandexamples/retry-policyexamples/concurrency-limitsexamples/failure-policyexamples/multi-workerexamples/testingexamples/observability-slogexamples/observability-otelexamples/managed-serviceexamples/production-composition
Optional Workerkit-specific HTTP controls are demonstrated separately in
examples/opshttp-basic,
examples/opshttp-commands, and
examples/admin-lifecycle.
The canonical symbol-level API documentation should live in Go doc comments so it stays accurate in editors and Go tooling. The repository-level companion is docs/api.md, which groups the exported surface into a human-oriented map.
This repository uses make for local verification:
make verify
make build-examples
make test-race
make govulncheckmake verify checks formatting, runs go vet, runs tests, builds the runnable examples, and verifies that go.mod and go.sum are tidy. make build-examples is available when you only want to compile the runnable examples.
CI runs verification and race tests on the supported Go versions. Release tags are gated by those jobs plus govulncheck before publishing.
Workerkit is maintained as a small bootstrap library for worker lifecycle, readiness, command dispatch, retry policy, observability, and optional operations HTTP integration.
Bug reports, documentation fixes, and compatibility issues are welcome. Large feature additions are evaluated conservatively because Workerkit is intentionally not a workflow engine, job queue, scheduler, distributed orchestrator, or application framework.
Workerkit is a small open source library maintained on a best-effort basis.
The active development line lives on main, and that is the only line actively maintained unless explicitly noted otherwise. The minimum supported Go version is declared in go.mod, and the Go versions currently verified in CI are listed in .github/workflows/ci.yaml.
Compatibility-impacting changes should be called out explicitly in release notes or release descriptions. Long-lived maintenance branches and backports are not planned unless explicitly noted.