package bank
import "errors"
var ErrVersionConflict = errors.New("optimistic lock: version conflict")
var ErrInsufficientFunds = errors.New("insufficient funds")
type Account struct {
ID int64
Balance int64 // cents
Version int64
}
func (a *Account) ApplyDebit(amount int64) error {
if amount <= 0 {
return errors.New("amount must be positive")
}
if a.Balance < amount {
return ErrInsufficientFunds
}
a.Balance -= amount
return nil
}
package bank
import (
"context"
"database/sql"
)
type AccountStore struct {
db *sql.DB
}
func NewAccountStore(db *sql.DB) *AccountStore {
return &AccountStore{db: db}
}
func (s *AccountStore) GetByID(ctx context.Context, id int64) (*Account, error) {
const q = `SELECT id, balance, version FROM accounts WHERE id = $1`
acc := &Account{}
err := s.db.QueryRowContext(ctx, q, id).Scan(&acc.ID, &acc.Balance, &acc.Version)
if err != nil {
return nil, err
}
return acc, nil
}
func (s *AccountStore) Update(ctx context.Context, acc *Account) error {
const q = `
UPDATE accounts
SET balance = $1, version = version + 1
WHERE id = $2 AND version = $3`
res, err := s.db.ExecContext(ctx, q, acc.Balance, acc.ID, acc.Version)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrVersionConflict
}
acc.Version++
return nil
}
package bank
import (
"context"
"errors"
)
const maxRetries = 3
type AccountService struct {
store *AccountStore
}
func NewAccountService(store *AccountStore) *AccountService {
return &AccountService{store: store}
}
func (svc *AccountService) Withdraw(ctx context.Context, id, amount int64) (*Account, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
acc, err := svc.store.GetByID(ctx, id)
if err != nil {
return nil, err
}
if err := acc.ApplyDebit(amount); err != nil {
return nil, err
}
err = svc.store.Update(ctx, acc)
if err == nil {
return acc, nil
}
if !errors.Is(err, ErrVersionConflict) {
return nil, err
}
lastErr = err // conflict: reload and retry
}
return nil, lastErr
}
Optimistic locking is a concurrency-control strategy that assumes conflicts are rare: instead of holding a database row lock for the duration of a read-modify-write cycle, each row carries a version counter, and every UPDATE only succeeds if the version in the database still matches the version the caller last read. This snippet shows the full loop across three collaborating files — the domain type and its repository, the store's version-checked update, and a service that retries on conflict.
In account.go, the Account struct exposes a Version int64 field alongside its business data. The important detail is that Version is part of the value the caller loads and later writes back, so it acts as a snapshot token. The ErrVersionConflict sentinel lets higher layers distinguish a genuine concurrency clash from other failures, and ApplyDebit performs the pure in-memory mutation without touching the database.
The heart of the pattern lives in account_store.go. GetByID reads the current row, including its version. Update issues a single atomic statement: UPDATE accounts SET ... version = version + 1 WHERE id = $x AND version = $y. Because the WHERE clause pins the expected version, the write is a compare-and-swap. sql.Result.RowsAffected() reveals the outcome: zero rows means some other transaction already bumped the version between the read and the write, so Update returns ErrVersionConflict. When it succeeds, the in-memory acc.Version is incremented so the same struct stays consistent for any subsequent write.
account_service.go closes the loop with Withdraw, which wraps the read-modify-write in a bounded retry against maxRetries. On each attempt it re-reads a fresh copy with the latest version, applies ApplyDebit, and calls Update; a returned ErrVersionConflict simply triggers another attempt, while any other error aborts immediately.
The trade-off is deliberate: optimistic locking avoids the contention and deadlock risk of SELECT ... FOR UPDATE, which makes it excellent for low-contention, read-heavy workloads. Its weakness is wasted work under high contention, where retries pile up — pessimistic locking or serialized queues fit better there. A common pitfall is forgetting the version = $expected predicate, which silently reintroduces lost updates, so the guard belongs in every write path that touches the row.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
Share this code
Here's the card — post it anywhere.