skip to content

In a Ruby admin panel that dispatches each button's action name to a service object's method, should it use send or public_send, and what else guards the dispatch?

level: seniorimportance: should knowfreq 36%

answer

  1. visibility as the first fence
  2. protected refused from outside
  3. inherited public methods still reachable
  4. public_send(:send, ...) bypass
  5. frozen allowlist before dispatch

basics

~20 s

public_send, so private and protected helpers stay unreachable. Visibility alone is not an allowlist: inherited public methods such as instance_variable_set and send remain callable, so check the name against a frozen list of allowed actions before dispatching.

solid answer

~30 s

I would use `public_send`, because the dispatcher stands in for an outside call and the service's `private` section should keep internal steps out of reach; `send` would happily call `charge_card_without_checks`. But `public_send` only enforces visibility. Every object answers public methods it inherits, like `instance_variable_set`, `instance_eval` and `send` itself, and `service.public_send(:send, :purge)` reaches a private method. So I check the submitted String against a frozen allowlist first, raise a handled error for unknown actions, and pass no request data as arguments. `respond_to?` is not a substitute: it confirms the method exists, not that it is an action.

code

ruby · 15 lines
ruby
class Account
  def initialize(balance) = @balance = balance

  def compare(other)
    [other.balance, other.public_send(:balance)]
  end

  protected

  def balance = @balance
end

Account.new(1).compare(Account.new(2))
# NoMethodError: protected method 'balance' called for an instance of Account
# (other.balance succeeded; public_send refused)

go deeper

for a junior

Recall that public_send refuses private and protected methods, while send reaches them.

for a middle

Explain why public_send is not an allowlist: inherited public methods, including send itself, stay reachable.

for a senior

Demonstrate the full guard: frozen allowlist on the raw String, handled unknown-action error, no request data as arguments, tests pinning the list.

for a principal

Decide between dynamic dispatch and an explicit table of callables for the team's internal tools, trading brevity against an auditable action list.

## The scenario An internal admin panel shows buttons such as *Refund*, *Resend receipt* and *Close account*. Each button submits an action name, and the controller dispatches it to a service object: ```ruby service = AdminActions.new(account) service.public_send(action_name) # "refund", "resend_receipt", ... ``` Dispatching by name keeps the controller short: adding an action means adding a method. The design questions are which dispatch method to use, and what must surround it. ## send or public_send `send` calls any method on the object, including **private** helpers such as `charge_card_without_checks` and **protected** ones. `public_send` calls only **public** methods and raises `NoMethodError` ("private method 'x' called for an instance of AdminActions") otherwise. For a dispatcher that stands in for an ordinary outside call, **`public_send` is the right default**: the service's visibility then decides what is an action and what is an internal step. Two details surprise people: 1. `public_send` refuses **protected** methods too, even when the caller is an instance of the same class. It treats every call as coming from outside. 2. `public_send` is **not an allowlist**. Every object also answers the public methods it inherits from `Object`, `Kernel` and `BasicObject`: `instance_variable_set`, `instance_eval`, `freeze`, `send` itself. `service.public_send(:send, :purge)` calls the private `purge`, because `send` is a public method and it ignores visibility. ## What else guards the dispatch | Guard | What it prevents | |---|---| | A frozen allowlist of action names | inherited public methods and helpers made public by mistake | | Comparing the raw String before converting | unknown input turning into dispatch at all | | One method per action, no arguments from the request | parameters controlling what the method does | | A handled error for unknown actions | a 500 page from `NoMethodError` | A minimal shape: ```ruby class AdminActions ACTIONS = %w[refund resend_receipt close_account].freeze def self.dispatch(service, action_name) raise ArgumentError, "unknown action: #{action_name}" unless ACTIONS.include?(action_name) service.public_send(action_name) end def refund = :refunded def resend_receipt = :sent def close_account = :closed private def charge_card_without_checks = :charged end AdminActions.dispatch(AdminActions.new, "refund") # => :refunded AdminActions.dispatch(AdminActions.new, "instance_variable_set") # ArgumentError ``` The allowlist check runs on the String the button submitted. Only a name that passed it is used for dispatch, which also keeps the error message about "unknown action" rather than about Ruby internals. ## Why not respond_to? `service.respond_to?(action_name)` looks like a guard but answers a different question: whether the object has a public method with that name. It says yes for `freeze`, `instance_variable_set` and `send`. With `true` as the second argument it also says yes for private methods. It checks capability, not permission. ## Alternatives worth naming - **An explicit Hash of callables** (`{"refund" => ->(s) { s.refund }}`) makes the list and the dispatch the same object; nothing outside the Hash is reachable. - **A `case` statement** is longer but greppable, and a reader sees every action in one place. - **Dynamic dispatch with an allowlist** stays attractive when actions are many and each one is already a well-named public method. ## Testing it - Assert that every name in the allowlist is a public method of the service: `AdminActions.public_method_defined?(name)` for each name catches a renamed method. - Assert that a private helper's name and an inherited method's name are rejected before dispatch. - Keep tests on the public actions; reaching private helpers with `send` in tests couples them to internals. ## Logging and auditing Dispatch by name hides the call from a plain text search: nobody greps for `public_send(action_name)` when they want to know who can refund an order. Two habits keep it traceable: 1. Log the action name and the acting admin before dispatch, so the audit trail shows the String that selected the method. 2. Keep the allowlist next to the methods it names, in the same class, so a reviewer who adds or renames an action sees the list in the same diff. ## What a senior answer sounds like It picks `public_send` and says why, then immediately says visibility is not enough: inherited public methods, `send` itself and a method made public by accident are all one button away. The fix is an explicit list of allowed names checked before dispatch, plus an error for unknown actions.

  • Why is service.respond_to?(action_name) a weak guard for this dispatcher?
    It reports capability, not permission. It returns true for every public method the object inherits, such as `freeze`, `instance_variable_set` and `send`, and with `true` as the second argument it includes private methods too. An explicit list of allowed action names is the only check that expresses which methods are meant to be buttons.
  • What does an explicit Hash of lambdas buy over dynamic dispatch here?
    The list of actions and the way each one runs become one object, so nothing outside the Hash is reachable and a reader sees every action in one place. The cost is duplication: each action is named twice. Dynamic dispatch with an allowlist is shorter when actions are many and already exist as public methods.

saying these in an interview costs you the question

  • public_send is enough because only intended methods are public
  • send is fine here since the admin panel is internal
  • respond_to? proves the name is a permitted action
  • public_send lets an instance call protected methods of its own class
  • converting the name with to_sym first makes the dispatch safe