{"title":"Transactions","description":"Database transactions and locking strategies in Grant ORM","section":"guides/models/grant","version":"v2","path":"guides/models/grant/transactions","canonical_url":"https://amberframework.org/docs/v2/guides/models/grant/transactions","markdown_url":"https://amberframework.org/docs/v2/guides/models/grant/transactions.md","inherited":false,"content_markdown":"# Transactions\n\n> **Supported web path:** Amber CLI `2.0.6` includes Grant in every generated\n> web application and pins the reviewed V2 commit. Preserve that pin while\n> following this beta.\n\n## Where the examples go\n\nTransaction and locking expressions run from the controller, job, service, or\nspec that owns the multi-record operation. Optimistic-locking columns and\ntransaction callback declarations belong inside the matching Grant model under\n`src/models/`. Shared financial or inventory workflows should live in a service\nunder `src/services/` with focused specs. Blocks on this page use those\ndestinations unless a closer comment identifies a different role.\n\nUse a transaction when several writes must commit or roll back together. Grant\nalso exposes isolation and locking controls for workflows that coordinate\nconcurrent database changes.\n\n## Basic Transactions\n\n```crystal\nGrant::Base.transaction do\n  user = User.find!(1)\n  user.balance -= 100\n  user.save!\n\n  transfer = Transfer.create!(\n    user_id: user.id,\n    amount: -100\n  )\n\n  # Automatic rollback on exception\n  raise \"Insufficient funds\" if user.balance < 0\nend\n```\n\n### Transaction Methods\n\n```crystal\n# Block syntax\nGrant::Base.transaction do\n  # All operations in one transaction\n  User.create!(name: \"Alice\")\n  User.create!(name: \"Bob\")\nend\n\n# With explicit rollback\nGrant::Base.transaction do |tx|\n  user = User.create!(name: \"Alice\")\n\n  if some_condition_fails\n    raise DB::Rollback.new(\"Condition failed\")\n  end\nend\n```\n\n## Nested Transactions with Savepoints\n\n```crystal\nGrant::Base.transaction do\n  order = Order.create!(customer_id: 1, total: 0)\n\n  items.each do |item_data|\n    Grant::Base.transaction do  # Savepoint\n      item = OrderItem.create!(\n        order_id: order.id,\n        product_id: item_data[:product_id],\n        quantity: item_data[:quantity]\n      )\n\n      product = Product.find!(item_data[:product_id])\n      product.stock -= item_data[:quantity]\n\n      # Rollback just this item if out of stock\n      raise \"Out of stock\" if product.stock < 0\n\n      product.save!\n      order.total += item.subtotal\n    end\n  rescue\n    # Skip item but continue with order\n    Log.warn { \"Skipping item #{item_data[:id]}\" }\n  end\n\n  order.save!\nend\n```\n\n## Isolation Levels\n\n```crystal\n# Available levels\nIsolationLevel::ReadUncommitted\nIsolationLevel::ReadCommitted\nIsolationLevel::RepeatableRead\nIsolationLevel::Serializable\n\n# Serializable for financial operations\nGrant::Base.transaction(isolation: :serializable) do\n  account1 = Account.find!(1)\n  account2 = Account.find!(2)\n\n  account1.balance -= 100\n  account2.balance += 100\n\n  account1.save!\n  account2.save!\nend\n\n# Read committed for reports\nGrant::Base.transaction(isolation: :read_committed) do\n  generate_report\nend\n```\n\n## Pessimistic Locking\n\nLock rows to prevent concurrent modifications.\n\n### Row-Level Locking\n\n```crystal\nGrant::Base.transaction do\n  # Lock account for update\n  account = Account.find!(1)\n  account.lock!  # FOR UPDATE\n\n  # No other transaction can modify this account\n  account.balance -= 100\n  account.save!\nend\n\n# Lock with custom mode\nGrant::Base.transaction do\n  account = Account.lock!(:share)  # FOR SHARE\n  # Read but prevent updates\nend\n```\n\n### with_lock Helper\n\n```crystal\naccount = Account.find!(1)\n\naccount.with_lock do |locked_account|\n  locked_account.balance -= 100\n  locked_account.save!\nend\n```\n\n### Lock Multiple Rows\n\n```crystal\nGrant::Base.transaction do\n  accounts = Account.where(user_id: 1).lock\n  accounts.each do |account|\n    account.process_fees\n  end\nend\n```\n\n## Optimistic Locking\n\nUse a version column to detect concurrent modifications.\n\n```crystal\nclass Product < Grant::Base\n  include Grant::Locking::Optimistic\n\n  column id : Int64, primary: true\n  column name : String\n  column price : Float64\n  column lock_version : Int32 = 0\nend\n\n# Automatic version checking\nproduct = Product.find!(1)\nproduct.price = 29.99\nproduct.save!  # Increments lock_version\n\n# Concurrent update detection\nproduct1 = Product.find!(1)\nproduct2 = Product.find!(1)\n\nproduct1.price = 19.99\nproduct1.save!  # Works\n\nproduct2.price = 24.99\nproduct2.save!  # Raises Grant::StaleRecordError\n```\n\n### Handling Conflicts\n\n```crystal\ndef update_with_retry(product, max_retries = 3)\n  retry_count = 0\n\n  loop do\n    begin\n      yield product\n      product.save!\n      break\n    rescue Grant::StaleRecordError\n      retry_count += 1\n      raise if retry_count >= max_retries\n\n      product.reload\n      Log.info { \"Retrying update (attempt #{retry_count})\" }\n    end\n  end\nend\n\nupdate_with_retry(product) do |p|\n  p.stock -= 1\nend\n```\n\n## Deadlock Prevention\n\n### Ordered Locking\n\nAlways acquire locks in the same order to prevent deadlocks.\n\n```crystal\ndef transfer_funds(from_id, to_id, amount)\n  # Sort IDs to ensure consistent lock order\n  ids = [from_id, to_id].sort\n\n  Grant::Base.transaction do\n    accounts = ids.map { |id| Account.find_and_lock!(id) }\n    from = accounts.find { |a| a.id == from_id }.not_nil!\n    to = accounts.find { |a| a.id == to_id }.not_nil!\n\n    from.balance -= amount\n    to.balance += amount\n\n    from.save!\n    to.save!\n  end\nend\n```\n\n### Lock Timeouts\n\n```crystal\nGrant::Base.transaction do\n  Grant.connection.exec(\"SET LOCAL lock_timeout = '5s'\")\n\n  begin\n    account = Account.find_and_lock!(1)\n    account.process!\n  rescue ex : DB::Error\n    if ex.message.includes?(\"lock timeout\")\n      Log.warn { \"Lock timeout, retrying...\" }\n    end\n    raise ex\n  end\nend\n```\n\n## Transaction Callbacks\n\n```crystal\nclass Order < Grant::Base\n  after_commit :send_confirmation, on: :create\n  after_commit :update_inventory, on: :update\n  after_rollback :log_failure\n\n  private def send_confirmation\n    # Safe - transaction committed\n    OrderMailer.confirmation(self).deliver_later\n  end\n\n  private def update_inventory\n    InventoryService.sync(self)\n  end\n\n  private def log_failure\n    Log.error { \"Order #{id} failed to save\" }\n  end\nend\n```\n\n## Best Practices\n\n### 1. Keep Transactions Short\n\n```crystal\n# Good: Short transaction\nGrant::Base.transaction do\n  user.update!(status: \"active\")\nend\n\n# Bad: Long transaction\nGrant::Base.transaction do\n  users = User.all.to_a\n  users.each do |user|\n    user.process_complex_logic  # Time-consuming\n    user.save!\n  end\nend\n```\n\n### 2. Use Appropriate Isolation\n\n```crystal\n# Serializable for critical financial operations\nGrant::Base.transaction(isolation: :serializable) do\n  transfer_funds(from, to, amount)\nend\n\n# Read committed for reports (better performance)\nGrant::Base.transaction(isolation: :read_committed) do\n  generate_report\nend\n```\n\n### 3. Handle Failures Gracefully\n\n```crystal\ndef process_order(order)\n  Grant::Base.transaction do\n    order.process!\n    Payment.charge!(order)\n    Inventory.decrement!(order)\n  end\nrescue Grant::RecordInvalid => e\n  Log.error { \"Validation failed: #{e.message}\" }\n  order.update!(status: \"failed\")\nrescue => e\n  Log.error { \"Order processing failed: #{e.message}\" }\n  raise\nend\n```\n\n### 4. Test Transaction Behavior\n\n```crystal\ndescribe \"Transfer funds\" do\n  it \"rolls back on failure\" do\n    account1 = Account.create!(balance: 100)\n    account2 = Account.create!(balance: 50)\n\n    expect_raises(Exception) do\n      Grant::Base.transaction do\n        account1.balance -= 200  # More than available\n        account2.balance += 200\n        account1.save!\n        account2.save!\n        raise \"Insufficient funds\"\n      end\n    end\n\n    # Both accounts unchanged\n    account1.reload.balance.should eq(100)\n    account2.reload.balance.should eq(50)\n  end\nend\n```"}