In martial arts, a kata is a precise sequence of movements - executed with full commitment, or not at all. If you break the form, you return to the beginning.
kata is an embedded Go library for orchestrating multi-step operations with automatic compensation on failure. No external services, no databases, no brokers - just import and use.
runner := kata.New(
kata.Step("charge-card", chargeCard).Compensate(refundCard).Retry(3, kata.Exponential(100*time.Millisecond)),
kata.Step("reserve-stock", reserveStock).Compensate(releaseStock),
kata.Step("create-shipment", createShipment),
)
if err := runner.Run(ctx, &OrderState{CardToken: "tok_123", Amount: 9900}); err != nil {
// all compensations already ran automatically
}If create-shipment fails, kata automatically calls releaseStock then refundCard - in reverse order, with the full state available.
Every non-trivial service has operations that span multiple steps: charge a card, reserve inventory, create a shipment. When step 3 fails, you need to undo steps 1 and 2. Most teams write this rollback logic by hand - scattered defer calls, nested if err != nil blocks, easy to get wrong.
The alternatives are either too heavy (Temporal, Cadence require a dedicated server cluster) or too primitive (existing Go saga libraries have no generics, no retry, no parallel execution).
kata sits in the middle: zero dependencies, idiomatic Go, production-ready features.
go get github.com/kerlenton/kataRequires Go 1.22+.
A Step is a named operation that reads from and writes to your shared state. Each step can optionally define a compensation (rollback) function.
kata.Step("charge-card", func(ctx context.Context, s *OrderState) error {
id, err := stripe.Charge(s.CardToken, s.Amount)
if err != nil {
return err
}
s.ChargeID = id // store result for later steps (and compensation)
return nil
}).Compensate(func(ctx context.Context, s *OrderState) error {
return stripe.Refund(s.ChargeID)
})Steps can be retried with configurable backoff:
kata.Step("call-flaky-api", callAPI).
Retry(3, kata.Exponential(100*time.Millisecond))
// attempts: immediate -> 100ms -> 200ms -> 400ms
kata.Step("call-another", callOther).
Retry(5, kata.Fixed(1*time.Second))
kata.Step("call-fast", callFast).
Retry(2, kata.NoDelay)kata.Step("slow-step", doWork).
Timeout(5 * time.Second)If the step exceeds the timeout, the context is cancelled and the step fails with context.DeadlineExceeded. Compensations are triggered normally.
Run multiple steps concurrently within a group. If any step in the group fails, the others are cancelled and the successful ones are compensated.
kata.Parallel("notify-customer",
kata.Step("send-email", sendEmail),
kata.Step("send-sms", sendSMS).Compensate(cancelSMS),
kata.Step("send-push", sendPush),
)If a later sequential step fails after the parallel group succeeds, all steps in the group are compensated in reverse order.
New creates a reusable runner - define it once, call Run per request:
// define once (e.g. at startup or in a constructor)
var orderRunner = kata.New(
kata.Step("charge", chargeCard).Compensate(refundCard),
kata.Step("reserve", reserveStock).Compensate(releaseStock),
kata.Parallel("notify",
kata.Step("email", sendEmail),
kata.Step("sms", sendSMS),
),
)
// call per request
func (s *OrderService) PlaceOrder(ctx context.Context, req *PlaceOrderRequest) error {
state := &OrderState{CardToken: req.CardToken, ItemID: req.ItemID}
return orderRunner.Run(ctx, state)
}kata distinguishes between two failure modes:
err := runner.Run(ctx, state)
var stepErr *kata.StepError
var compErr *kata.CompensationError
switch {
case err == nil:
// all steps succeeded
case errors.As(err, &stepErr):
// a step failed, all compensations ran successfully
// stepErr.StepName - which step failed
// stepErr.Cause - the original error
log.Printf("rolled back cleanly after %q: %v", stepErr.StepName, stepErr.Cause)
case errors.As(err, &compErr):
// a step failed AND one or more compensations also failed
// the system may be in a partially inconsistent state
// manual intervention may be required
log.Printf("ALERT: step %q failed, compensations also failed:", compErr.StepName)
for _, f := range compErr.Failed {
log.Printf(" - %q: %v", f.StepName, f.Err)
}
}Attach hooks for logging, metrics, or tracing - no changes to step code required:
runner := kata.New(steps...).WithOptions(
kata.WithHooks(kata.Hooks{
OnStepStart: func(ctx context.Context, name string) {
metrics.Inc("kata.step.started", name)
},
OnStepDone: func(ctx context.Context, name string, d time.Duration) {
metrics.Histogram("kata.step.duration", d, name)
},
OnStepFailed: func(ctx context.Context, name string, err error) {
log.Errorf("step %q failed: %v", name, err)
},
OnCompensationStart: func(ctx context.Context, name string) {
log.Warnf("compensating %q", name)
},
OnCompensationFailed: func(ctx context.Context, name string, err error) {
alerts.Fire("compensation_failed", name, err)
},
}),
)Available hooks:
| Hook | When |
|---|---|
OnStepStart |
Before a step begins |
OnStepDone |
After a step succeeds |
OnStepFailed |
After a step exhausts all retries and fails |
OnCompensationStart |
Before a compensation begins |
OnCompensationDone |
After a compensation succeeds |
OnCompensationFailed |
After a compensation fails |
type OrderState struct {
// inputs
CardToken string
ItemID string
UserEmail string
Amount int64
// filled in by steps
ChargeID string
ReservationID string
}
var orderRunner = kata.New(
kata.Step("charge-card", func(ctx context.Context, s *OrderState) error {
id, err := payments.Charge(ctx, s.CardToken, s.Amount)
s.ChargeID = id
return err
}).Compensate(func(ctx context.Context, s *OrderState) error {
return payments.Refund(ctx, s.ChargeID)
}).Retry(3, kata.Exponential(100*time.Millisecond)).Timeout(10*time.Second),
kata.Step("reserve-stock", func(ctx context.Context, s *OrderState) error {
id, err := warehouse.Reserve(ctx, s.ItemID)
s.ReservationID = id
return err
}).Compensate(func(ctx context.Context, s *OrderState) error {
return warehouse.Release(ctx, s.ReservationID)
}),
kata.Step("create-shipment", func(ctx context.Context, s *OrderState) error {
return shipping.Create(ctx, s.ReservationID)
}),
kata.Parallel("notify",
kata.Step("email", func(ctx context.Context, s *OrderState) error {
return mailer.Send(ctx, s.UserEmail, "Your order is confirmed!")
}),
kata.Step("analytics", func(ctx context.Context, s *OrderState) error {
return analytics.Track(ctx, "order_placed", s.ItemID)
}),
),
)
func PlaceOrder(ctx context.Context, req *Request) error {
state := &OrderState{
CardToken: req.CardToken,
ItemID: req.ItemID,
UserEmail: req.UserEmail,
Amount: req.Amount,
}
err := orderRunner.Run(ctx, state)
if err != nil {
var compErr *kata.CompensationError
if errors.As(err, &compErr) {
// compensation failed - alert on-call
pagerduty.Fire(compErr)
}
return err
}
return nil
}| kata | Temporal/Cadence | floxy | go-saga | |
|---|---|---|---|---|
| External service required | ✗ | ✓ (server cluster) | ✗ | ✗ |
| Persistent state | plug-in | ✓ | PostgreSQL | ✗ |
| Generics (typed state) | ✓ | ✗ | ✗ | ✗ |
| Parallel steps | ✓ | ✓ | ✓ | ✗ |
| Per-step retry + backoff | ✓ | ✓ | ✓ | ✗ |
| Per-step timeout | ✓ | ✓ | ✗ | ✗ |
| Observability hooks | ✓ | ✓ | ✗ | ✗ |
| Zero dependencies | ✓ | ✗ | ✗ | ✓ |
MIT