ruby 105 lines · 3 tabs

Transactional Order Creation With Nested Savepoints in Rails

Shared by codesnips Sep 2026
3 tabs
class OrderCreationService
  Result = Struct.new(:success?, :order, :error, keyword_init: true)

  def initialize(customer:, line_params:)
    @customer = customer
    @line_params = line_params
  end

  def call
    order = nil
    ActiveRecord::Base.transaction do
      order = build_order
      order.save!
      reserved = reserve_inventory(order)

      if reserved.zero?
        order.errors.add(:base, "No items could be reserved")
        raise ActiveRecord::Rollback
      end

      order.update!(status: :confirmed, reserved_count: reserved)
    end

    return failure(order) if order.nil? || order.status != "confirmed"
    Result.new(success?: true, order: order)
  rescue ActiveRecord::RecordInvalid => e
    Result.new(success?: false, error: e.record.errors.full_messages.to_sentence)
  end

  private

  def build_order
    order = @customer.orders.build(status: :pending)
    @line_params.each do |lp|
      order.line_items.build(inventory_item_id: lp[:item_id], quantity: lp[:quantity])
    end
    order
  end

  def reserve_inventory(order)
    reserved = 0
    order.line_items.each do |line|
      # Each item gets its own SAVEPOINT so a stockout skips just this line.
      ActiveRecord::Base.transaction(requires_new: true) do
        line.inventory_item.reserve!(line.quantity)
        line.update!(reserved: true)
        reserved += 1
      end
    rescue InventoryItem::InsufficientStock
      line.update_column(:reserved, false)
    end
    reserved
  end

  def failure(order)
    msg = order&.errors&.full_messages&.to_sentence.presence || "Order could not be created"
    Result.new(success?: false, error: msg)
  end
end
3 files · ruby Explain with highlit

This snippet shows how a multi-step order creation flow is made atomic in Rails using a top-level database transaction plus nested savepoints so that individual sub-steps can fail and roll back independently without aborting the whole operation.

In OrderCreationService, the entire process runs inside ActiveRecord::Base.transaction. This is the outer boundary: if any unhandled error escapes call, every write — the order, its line items, and the reservation — is discarded together. That guarantees the caller never sees a half-built order. The Result struct at the top makes the service's contract explicit: success carries the persisted order, failure carries an error message, so the controller can branch without rescuing exceptions itself.

The interesting part is reserve_inventory. Inventory reservation is best-effort per line item — some items may be out of stock, and the business rule is to skip those rather than fail the order. Wrapping each item in ActiveRecord::Base.transaction(requires_new: true) opens a SQL SAVEPOINT. When reserve! raises InsufficientStock, only the writes since that savepoint are rolled back; the outer transaction and previously reserved items survive. This is why requires_new: true matters — without it, ActiveRecord reuses the existing transaction and a raised-and-rescued error would leave the connection in an aborted state, poisoning subsequent statements.

A subtle Postgres pitfall is addressed here: once a statement inside a transaction errors, the connection refuses further work until rollback. The savepoint gives a clean rollback target, so the loop can continue. The service also raises ActiveRecord::Rollback in call when no items could be reserved, which unwinds the outer transaction quietly without propagating an exception.

In InventoryItem model, reserve! performs an atomic conditional UPDATE guarded by a WHERE available >= ? clause and checks the affected row count, which prevents overselling under concurrency far more reliably than a read-then-write check.

Finally, OrdersController simply calls the service and maps the Result to either a 201 or 422 response. This pattern is worth reaching for whenever a workflow spans several tables and some steps are optional or fallible: the outer transaction enforces all-or-nothing on the critical path, while savepoints isolate the parts allowed to fail gracefully.


Related snips

Share this code

Here's the card — post it anywhere.

Transactional Order Creation With Nested Savepoints in Rails — share card
Link copied