/ Tags: RAILS / Categories: RAILS

with_options — DRY Validations and Routes Without the Abstraction Tax

Six validations in a row, each ending with the same if: :submitted?. Four routes sharing the same constraint. Three associations with identical dependent: :destroy, inverse_of: options. The repetition is noise, but the usual fix — extracting a method, building a concern, writing a custom validator — costs more indirection than the duplication did. with_options sits exactly in that gap.

What It Does


with_options takes a hash and yields a proxy. Every method called on that proxy gets the hash merged into its final argument.

Example:

class Application < ApplicationRecord
  validates :legal_name,   presence: true, if: :submitted?
  validates :tax_id,       presence: true, if: :submitted?
  validates :address_line, presence: true, if: :submitted?
  validates :postal_code,  presence: true, if: :submitted?
  validates :contact_email, presence: true, format: { with: EMAIL_RE }, if: :submitted?
end

Example:

class Application < ApplicationRecord
  with_options if: :submitted? do |submitted|
    submitted.validates :legal_name,   presence: true
    submitted.validates :tax_id,       presence: true
    submitted.validates :address_line, presence: true
    submitted.validates :postal_code,  presence: true
    submitted.validates :contact_email, presence: true, format: { with: EMAIL_RE }
  end
end

The condition is stated once, at the top, where it reads as a heading rather than as a repeated suffix. Nothing is hidden — the block is right there, the merge is mechanical, and someone reading it doesn’t have to jump to another file to find out what SubmittedValidations does.

The block parameter is conventional but not required; without it, the proxy becomes the implicit receiver:

with_options if: :submitted? do
  validates :legal_name, presence: true
end

Naming the parameter is clearer in a long class body, where an unnamed block leaves you wondering which validates calls are inside it. Name it after the condition and the code reads as prose.

Where It Earns Its Place


1. Conditional validation groups

The canonical case, above. Multi-step forms, draft/published states, role-dependent requirements.

2. Associations with shared options
class Account < ApplicationRecord
  with_options dependent: :destroy, inverse_of: :account do |assoc|
    assoc.has_many :invoices
    assoc.has_many :subscriptions
    assoc.has_many :api_keys
    assoc.has_many :webhooks
  end
end

Forgetting inverse_of on one association out of six is a real bug — it causes extra queries and, with validates_presence_of on the parent, spurious validation failures on nested saves. Declaring it once removes the chance of an inconsistency.

3. Routes
Rails.application.routes.draw do
  namespace :api do
    with_options defaults: { format: :json }, constraints: { id: /\d+/ } do |api|
      api.resources :orders
      api.resources :invoices
      api.resources :customers
    end
  end
end
4. Scopes and callbacks
class Order < ApplicationRecord
  with_options if: :paid?, on: :update do |paid|
    paid.after_commit :notify_fulfillment
    paid.after_commit :send_receipt
    paid.after_commit :update_ledger
  end
end
5. Delegation
class OrderPresenter
  with_options to: :order, allow_nil: true do |o|
    o.delegate :total, :currency, :placed_at
    o.delegate :name, to: :customer, prefix: true
  end
end

Note the second line — an explicitly passed to: overrides the shared one. Explicit arguments always win over the merged defaults, which is what makes exceptions inside a block possible.

The Merge Semantics You Need to Know


This is where surprises come from. The merge is shallow, applied to the last hash argument.

Example:

with_options presence: true do |required|
  required.validates :email, format: { with: EMAIL_RE }
  # => validates :email, format: { with: EMAIL_RE }, presence: true
end

Fine. But:

Example:

with_options numericality: { greater_than: 0 } do |positive|
  positive.validates :quantity, numericality: { only_integer: true }
  # => numericality: { only_integer: true }
  #    NOT { greater_than: 0, only_integer: true }
end

The inner numericality: replaces the outer one wholesale. Shallow merge means same-key collisions are overwrites, not deep merges. If you need both constraints, state both explicitly on that line.

Multiple conditions don’t combine
with_options if: :submitted? do |s|
  s.validates :tax_id, presence: true, if: :business_account?
  # Only :business_account? applies — :submitted? is overwritten
end

To require both, pass an array:

s.validates :tax_id, presence: true, if: [:submitted?, :business_account?]

Or restructure with nesting:

with_options if: :submitted? do |s|
  s.with_options if: :business_account? do |biz|
    # still overwrites — nesting doesn't merge conditions either
  end
end

Nesting merges the hashes, and the inner if: still wins on key collision. There is no built-in AND-combination of conditions; use the array form.

Where Not to Use It


with_options is a syntactic convenience. It’s the wrong tool when the grouping represents a real concept.

  • When the group has behaviour, not just options. Five validations plus two methods plus a callback is a concern, not a with_options block.
  • When you’d reuse it across models. with_options is local to one class body. Shared validation logic belongs in a custom validator or a concern.
  • When the block gets long. Past ten or so lines, the shared option at the top scrolls off screen and the benefit inverts — a reader in the middle of the block has no idea a condition applies.
  • When only two lines share the option. The duplication was cheaper than the block.

The honest test: does the block make the class easier to read for someone seeing it for the first time? If the answer requires explaining with_options first, it isn’t helping.

Pro-Tip: The failure mode that costs real debugging time is a with_options block that grows past a screen. A developer adds a validation at line 340, inside a block that opened at line 290 with if: :submitted?, and never notices the condition — so the validation silently doesn’t run for draft records, and the bug surfaces months later as bad data rather than as an error. Two defences: keep blocks short enough to see both ends at once, and write a spec that asserts the negative case for each conditional group — expect(Application.new(status: :draft)).to be_valid with none of the submitted fields set. That single spec catches both a missing condition and an accidental one, and it fails immediately when someone adds a validation in the wrong place.

How It Works


Worth knowing, because it explains the limits. with_options returns an ActiveSupport::OptionMerger, which is a BasicObject using method_missing to intercept every call, merge the stored hash into the last argument if it’s a hash (or append it if there isn’t one), and forward to the original receiver.

That’s the whole implementation. Consequences:

  • It works with any method that takes an options hash as its final argument — including your own methods, not just Rails’ DSL
  • It cannot merge into anything that isn’t the last argument
  • Methods taking keyword arguments work in modern Rails, which special-cases them; very old versions did not
  • Because it’s BasicObject, the proxy has almost no methods of its own, so inspect on it is unhelpful when debugging

You can use it on your own DSLs for free:

class Dashboard
  with_options refresh: 60, cache: true do |live|
    live.add_widget :revenue, span: 6
    live.add_widget :signups, span: 3
    live.add_widget :churn,   span: 3
  end
end

Conclusion


with_options removes repeated option hashes without introducing a new name, a new file, or a new concept for the next reader to learn — which is exactly the right trade when the only thing shared is an option, not a behaviour. Use it for conditional validation groups, association defaults, namespaced route constraints, and delegation. Remember the merge is shallow and same-key collisions overwrite rather than combine, keep blocks short enough to see both ends, and reach for a concern the moment the group starts carrying logic instead of configuration.

FAQs


Q1: Does with_options work outside ActiveRecord models?
Yes. It’s defined on Object by ActiveSupport, so it works in any class — presenters, service objects, route files, your own DSLs — on any method whose last argument is an options hash.

Q2: Can I nest with_options blocks?
Yes, and the hashes merge outward-in with the inner one winning on collisions. It reads poorly beyond one level of nesting, so treat two levels as the practical maximum.

Q3: Why is my if: condition being ignored inside the block?
Because that line passes its own if:, which overwrites the shared one — shallow merge, last writer wins. Combine them with an array: if: [:outer_condition, :inner_condition].

Q4: Is there a performance cost?
Only at class-definition time, and it’s negligible — the proxy exists during the block and the merged calls are ordinary method calls. Nothing persists into runtime.

Q5: How do I make one line in the block opt out of the shared option?
Pass the key explicitly with the value you want, including nil or false. Explicit arguments always override the merged defaults.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More