How to keep data consistent across services without distributed transactions, with a working Go example that reserves stock, charges a card and rolls back with compensating steps.
Splitting a backend into services gives each one its own database, which ends the option of wrapping "reserve stock, charge the card, confirm the order" in a single SQL transaction. Two-phase commit across services holds locks until every participant agrees, which ties each service's availability to all the others.
The saga pattern is the standard way to keep those services consistent, and this guide explains how sagas work, compares orchestration and choreography, and builds a checkout saga in Go with Encore, using Pub/Sub retries for durability and typed service calls for each step.
A saga, first described by Garcia-Molina and Salem in 1987 for long-lived database transactions, is a sequence of local transactions where each step has a compensating action. microservices.io describes it as a reliably coordinated sequence of local transactions, and the Azure architecture guide adds that compensations undo the changes earlier steps made when a later step fails.
For a checkout, the steps are reserve stock, then charge the card, then confirm the order. If the charge is declined, the compensation releases the reservation and the order is marked cancelled. Compensation is a business action, not a rollback: the stock row was committed and other orders may have seen the lower count in the meantime.
Encore is an open source backend toolkit for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. Each service here owns its own Postgres database, the saga starts from a Pub/Sub event, and steps are service calls that look like Go functions.

The orders service stores a pending order and publishes an event:
// orders/orders.go
package orders
import (
"context"
"encore.dev/pubsub"
"encore.dev/storage/sqldb"
)
var db = sqldb.NewDatabase("orders", sqldb.DatabaseConfig{Migrations: "./migrations"})
type OrderPlaced struct {
OrderID int64 `json:"order_id"`
SKU string `json:"sku"`
AmountCents int64 `json:"amount_cents"`
}
var OrderPlacedTopic = pubsub.NewTopic[*OrderPlaced]("order-placed", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
type PlaceParams struct {
SKU string `json:"sku"`
AmountCents int64 `json:"amount_cents"`
}
type PlaceResponse struct {
OrderID int64 `json:"order_id"`
}
//encore:api public method=POST path=/orders
func Place(ctx context.Context, p *PlaceParams) (*PlaceResponse, error) {
var id int64
err := db.QueryRow(ctx, `INSERT INTO orders (sku, amount_cents, status)
VALUES ($1, $2, 'pending') RETURNING id`, p.SKU, p.AmountCents).Scan(&id)
if err != nil {
return nil, err
}
_, err = OrderPlacedTopic.Publish(ctx, &OrderPlaced{OrderID: id, SKU: p.SKU, AmountCents: p.AmountCents})
return &PlaceResponse{OrderID: id}, err
}
func setStatus(ctx context.Context, id int64, status string) error {
_, err := db.Exec(ctx, `UPDATE orders SET status = $1 WHERE id = $2`, status, id)
return err
}
The migration in orders/migrations creates the orders table the queries use:
-- orders/migrations/1_create_orders.up.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
amount_cents BIGINT NOT NULL,
status TEXT NOT NULL
);
Run in your terminal to get started locally.
The saga itself is a subscription handler in the orders service. A declined card or missing stock is a business outcome, so the handler compensates and returns nil. Any other error is returned, and Pub/Sub delivers the event again under the subscription's retry policy:
// orders/saga.go
package orders
import (
"context"
"encore.app/inventory"
"encore.app/payments"
"encore.dev/beta/errs"
"encore.dev/pubsub"
)
var _ = pubsub.NewSubscription(OrderPlacedTopic, "checkout-saga",
pubsub.SubscriptionConfig[*OrderPlaced]{Handler: runCheckout})
func runCheckout(ctx context.Context, e *OrderPlaced) error {
// Step 1: reserve stock. Reserve is idempotent on OrderID, so retries are safe.
err := inventory.Reserve(ctx, &inventory.ReserveParams{OrderID: e.OrderID, SKU: e.SKU})
if errs.Code(err) == errs.FailedPrecondition {
return setStatus(ctx, e.OrderID, "rejected") // out of stock, nothing to undo
} else if err != nil {
return err
}
// Step 2: charge the card, using OrderID as the idempotency key.
err = payments.Charge(ctx, &payments.ChargeParams{OrderID: e.OrderID, AmountCents: e.AmountCents})
if errs.Code(err) == errs.FailedPrecondition {
// Compensate step 1, then record the outcome.
if err := inventory.Release(ctx, &inventory.ReleaseParams{OrderID: e.OrderID}); err != nil {
return err
}
return setStatus(ctx, e.OrderID, "cancelled")
} else if err != nil {
return err
}
return setStatus(ctx, e.OrderID, "confirmed")
}
Each step has to be safe to repeat, since a retry runs the handler from the top. The inventory service records reservations per order and only touches stock the first time:
// inventory/inventory.go
package inventory
import (
"context"
"errors"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
)
var db = sqldb.NewDatabase("inventory", sqldb.DatabaseConfig{Migrations: "./migrations"})
type ReserveParams struct {
OrderID int64 `json:"order_id"`
SKU string `json:"sku"`
}
//encore:api private
func Reserve(ctx context.Context, p *ReserveParams) error {
tx, err := db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(ctx, `INSERT INTO reservations (order_id, sku)
VALUES ($1, $2) ON CONFLICT (order_id) DO NOTHING`, p.OrderID, p.SKU)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return nil // already reserved by an earlier attempt
}
res, err = tx.Exec(ctx, `UPDATE stock SET available = available - 1
WHERE sku = $1 AND available > 0`, p.SKU)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return &errs.Error{Code: errs.FailedPrecondition, Message: "out of stock"}
}
return tx.Commit()
}
type ReleaseParams struct {
OrderID int64 `json:"order_id"`
}
//encore:api private
func Release(ctx context.Context, p *ReleaseParams) error {
tx, err := db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback()
var sku string
err = tx.QueryRow(ctx, `DELETE FROM reservations WHERE order_id = $1 RETURNING sku`,
p.OrderID).Scan(&sku)
if errors.Is(err, sqldb.ErrNoRows) {
return nil // already released
} else if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE stock SET available = available + 1 WHERE sku = $1`, sku); err != nil {
return err
}
return tx.Commit()
}
The migration in inventory/migrations creates the reservations and stock tables the queries use:
-- inventory/migrations/1_create_tables.up.sql
CREATE TABLE reservations (
order_id BIGINT PRIMARY KEY,
sku TEXT NOT NULL
);
CREATE TABLE stock (
sku TEXT PRIMARY KEY,
available INTEGER NOT NULL
);
The payments service follows the same shape: a private Charge endpoint that passes OrderID to the payment provider as an idempotency key and returns errs.FailedPrecondition when the card is declined. encore run starts Postgres locally for all three services and delivers Pub/Sub events on your machine, so you can test the decline path before deploying. In the cloud, each service gets its own database on RDS or Cloud SQL and the topic runs on SNS and SQS or GCP Pub/Sub in your account.
When an agent changes the checkout flow, the steps, compensations and retry boundary sit in one handler it can read top to bottom, and the tracing for each attempt shows which step failed.
One gap remains: Place writes the order and then publishes, and a crash between the two leaves a pending order with no event. Encore's Go SDK includes a transactional outbox that writes the event in the same database transaction and relays it after commit, following the outbox pattern.
An orchestrated saga has one component that runs each step and decides when to compensate, which is what the handler above does. The flow is easy to read and change, and the orchestrator's state shows where every order is. For long-running flows that wait hours for a human or an external system, a workflow engine such as Temporal persists the orchestrator's progress between steps.
In a choreographed saga, each service listens for the previous event and publishes its own: inventory reacts to OrderPlaced and publishes StockReserved, payments reacts to that and publishes PaymentFailed, and inventory reacts to the failure by releasing stock. No service owns the whole flow, which suits independent teams, but answering "what happens when a payment fails" means reading every subscriber, and cycles between services are easy to create.
Sagas give eventual consistency without isolation: between steps, other requests can see a reservation that will later be released, so design states such as pending that the rest of the system understands, and make every step and every compensation idempotent, because at-least-once delivery will run them more than once.
Use orchestration for most sagas inside one backend, since the flow lives in one place and compensations are explicit. Use choreography when steps belong to separate teams that should not depend on a central coordinator, and add a workflow engine when sagas run for hours or need human input.
Use Encore when you want the services, their databases, the triggering topic and the saga handler in one Go codebase that runs locally with encore run and deploys to your own AWS or GCP account.
A saga splits a business transaction that spans several services into a sequence of local transactions, one per service. If a later step fails, the saga runs compensating transactions that undo the earlier steps, for example releasing reserved stock after a declined payment, instead of relying on a distributed lock or two-phase commit.
In an orchestration saga one component calls each step in order and decides when to compensate, so the flow is in one place. In a choreography saga each service reacts to events from the previous one and publishes its own, so there is no central coordinator but the flow is spread across services.
With Encore, publish an event with a pubsub.NewTopic when the order is created and run the saga in a pubsub.NewSubscription handler. The handler calls each service as a Go function, returns nil after compensating a business failure, and returns an error on transient failures so Pub/Sub retries it.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.