Transactions
A transaction lets you make several writes commit or roll back together, as a unit. devedge-sdk
exposes this through one backend-neutral seam, persistence.TxRunner, and its Atomically
method. The repositories you call inside Atomically automatically join the transaction, so you
write your domain logic without any Begin/Commit plumbing.
Use a transaction when a write must span more than one operation atomically — for example, load
a parent, check its state, then write a child, all or nothing. You do not need a transaction for
the common case: the generated CRUD handler does a single Create, Update, or Delete, which
is already atomic at the row level. For the “read, modify, write one resource” case, use an
etag instead.
The seam
// persistence/tx.go
type TxRunner interface {
Atomically(ctx context.Context, fn func(ctx context.Context) error) error
}Atomically runs fn inside a single backend transaction. The work commits when fn returns
nil and rolls back when fn returns an error or panics. There is no Begin/Commit to manage
and no per-call-site WithTx(...) plumbing: the transaction travels on ctx.
Propagation is context-based, which keeps the core engine-neutral. Atomically stashes an opaque
backend handle on ctx (persistence.WithTx). A transaction-aware repository reads that handle
(persistence.TxFromContext) and binds its writes to the transaction for the duration of fn.
Package persistence never imports an ORM or driver — the handle is an any that only the
backend’s TxRunner and its generated repositories understand.
Check-then-write recipe
The canonical shape is “load a parent, validate it, then write a child” — for example, “an order item cannot be added once the order is SHIPPED”.
err := txRunner.Atomically(ctx, func(ctx context.Context) error {
parent, err := parents.Get(ctx, parentID) // tx-bound read
if err != nil {
return err
}
if parent.State == "SHIPPED" {
return status.Error(codes.FailedPrecondition, "fleet is shipped")
}
_, err = children.Create(ctx, child) // tx-bound write
return err // nil → commit, non-nil → roll back
})Two rules make this work:
- Pass the inner
ctxto every repository call insidefn. A call that uses the innerctxjoins the transaction; a call that uses the outerctx(or a repository that ignoresctx) does not. See Failure modes. - Nested
Atomicallycalls join the outer transaction. A nested call is a no-op begin, not a second transaction, so a helper that wraps its own work inAtomicallycomposes safely when called from within an outerAtomically.
Backends
Three TxRunner implementations ship as development defaults: ent, in-memory, and GORM.
ent
protoc-gen-ent generates a per-package EntTxRunner next to the repositories:
txRunner := apikeyv1.NewEntTxRunner(client) // same *ent.Client the repos useAtomically opens client.Tx(ctx), stashes the *ent.Tx on ctx, and the generated
repositories resolve tx.<Type> instead of the constructor client. Every Create, Update,
Delete, and Undelete inside fn runs on the transaction. The work commits on success and
rolls back on error or panic.
in-memory
persistence.NewMemoryTxRunner coordinates one or more MemoryRepository instances so a single
Atomically can span them — for example, a parent and a child repository:
parents := persistence.NewMemoryRepository(func(p *Parent) string { return p.Id })
children := persistence.NewMemoryRepository(func(c *Child) string { return c.Id })
txRunner := persistence.NewMemoryTxRunner(parents, children)It takes each participant’s write lock for the duration of fn and snapshots the maps on entry.
On error or panic it restores the snapshots (rollback); on success it keeps them (commit). Because
the write lock is held across fn, a concurrent reader blocks until the transaction completes and
never sees partial state.
Atomically may write to NewMemoryTxRunner. The in-memory
backend has no relational graph — it is flat maps for development and tests — so it can only span
the repositories you register with it.GORM
persistence/gormtx ships GormTxRunner, the sibling of EntTxRunner for the
protoc-gen-storage (GORM) backend:
txRunner := gormtx.NewGormTxRunner(db) // same *gorm.DB the generated repos useAtomically opens a GORM transaction (db.WithContext(ctx).Begin()), stashes the
transaction-scoped *gorm.DB on ctx, and the generated repositories resolve that handle (via
their conn(ctx) helper) instead of the constructor *gorm.DB. Every Create, Update,
Delete, and Undelete inside fn runs on the transaction; the work commits on success and
rolls back on error or panic. A nested call joins a GORM transaction already on ctx.
persistence/gormtx also ships GormOutboxStore and GormIdempotencyStore — the GORM-backed
transactional outbox and exactly-once idempotency stores — wired through the same OutboxStore
and IdempotencyStore seams. See Events.
Optimistic concurrency with etag
For a single-resource Update, the concurrency control is the resource etag (AIP-154), not a
transaction. The framework stamps a fresh etag on every Create and Update (EtagMixin on
the ent side, the in-memory repository on the dev side), and the middleware/etag interceptor
enforces the client’s If-Match:
- A client reads a resource and gets its
etag. - On
Update, the client echoes that value asIf-Match. - If the stored
etaghas moved on — someone else updated the resource in between — the update is rejected withpersistence.ErrPreconditionFailed, surfaced as a412or428.
This prevents a lost update without holding a transaction open across the read and the write. Use
etag/If-Match for the common “read, modify, write one resource” case; use Atomically when a
write must span more than one operation atomically.
Failure modes
A write on the outer ctx runs outside the transaction even though it sits inside fn. This
is the worst case, because the code looks atomic but is not. A write issued with the outer ctx,
or through a repository that ignores ctx, does not join the transaction.
Mitigation: the generated repositories are transaction-aware by default and bind from ctx, so
always pass the inner ctx. A write path that must be transactional can call
persistence.RequireTx(ctx) first — it returns persistence.ErrNoTransaction when ctx is not
enrolled — so a caller who forgot to wrap the work fails loudly rather than writing silently.
This guards only callers that opt in; it cannot prove that a non-transaction-aware adapter
participated.
- Two aggregates in one
Atomically. This is not prevented by the type system. Keep one consistency boundary per transaction. Cross-aggregate consistency is eventual — use the transactional outbox and domain events seam, not a two-aggregate transaction.
Aggregates
Transactions are the foundation for aggregates — a consistency boundary that spans several
resources with invariants enforced on write. The aggregate machinery (an AggregateRepository, a
fail-closed boundary gate, and member write-redirection) builds on this seam. See
Aggregates.