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
class InventoryItem < ApplicationRecord
class InsufficientStock < StandardError; end
has_many :line_items
def reserve!(quantity)
affected = self.class
.where(id: id)
.where("available >= ?", quantity)
.update_all("available = available - #{quantity.to_i}, reserved = reserved + #{quantity.to_i}")
raise InsufficientStock, "item #{id} short by #{quantity}" if affected.zero?
reload
end
end
class OrdersController < ApplicationController
def create
result = OrderCreationService.new(
customer: current_customer,
line_params: order_lines
).call
if result.success?
render json: serialize(result.order), status: :created
else
render json: { error: result.error }, status: :unprocessable_entity
end
end
private
def order_lines
params.require(:lines).map do |l|
{ item_id: l[:item_id], quantity: l[:quantity].to_i }
end
end
def serialize(order)
{
id: order.id,
status: order.status,
reserved_count: order.reserved_count,
lines: order.line_items.map { |li| { item_id: li.inventory_item_id, reserved: li.reserved } }
}
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
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
Share this code
Here's the card — post it anywhere.