def debit(amount, memo:)
Entry.create!(…)
end The problem
Finance::Accounts#debit takes an amount in integer pennies and returns the ledger entry it made.
You want it to take Money, and to stop returning the entry, because half the callers ignore it:
module Finance
class Accounts
def debit(amount, memo:)
Entry.create!(account: self, amount: -amount, memo:)
end
end
end
Two changes to one method’s shape. Which callers does each one break? Finding the callers is the easy
part. Knowing which of them pass an Integer, and which of them use the return value and how, means
reading every one of them and following each value by hand. The type checker doesn’t help: there are
no declared types to check.
The fix
Ask before you change it:
$ ra what_a_change_touches Finance::Accounts#debit
argument 1 · amount
Money Invoice#settle invoice.rb:41 1,204 calls
Money Refund#process refund.rb:22 57 calls
Integer Transfer#call transfer.rb:9 388 calls ← changes
Integer FeeSchedule#charge fee_schedule.rb:30 12 calls ← changes
return value · Finance::Entry
receives #id Refund#process refund.rb:23 ← changes
unused Invoice#settle, Transfer#call, FeeSchedule#charge
So the argument change touches two callers, both of which still pass pennies, and the return change
touches one, which reads #id off the entry to link the refund to it. Everything else is safe.
Make the change and fix exactly those three lines:
# transfer.rb:9
public_send(direction, Money.from_pennies(amount), memo:)
# fee_schedule.rb:30
account.debit(Money.from_pennies(fee), memo: "fee")
# refund.rb:22, the one caller that needs the entry
entry = ledger.record_debit(amount, memo: "refund #{id}")
update!(entry_id: entry.id)
record_debit keeps the old behaviour under a name that says it hands something back, and debit
stops pretending it does.
How it works
Three things in the recording answer it:
- Callers. Every recorded call edge into
debit, with the method and line it came from. - Argument classes. Each call records the class of every argument it passed, so each caller’s
amountis known to be aMoneyor anIntegerfrom the calls it actually made. - What happens to the return. Each call records the identity of the object it returned. Within
the same call tree, any later call whose receiver is that same object is a message sent to the
return value. Grouped by caller, that’s how each one uses what
debitgives back:Refundsends#id, the others send nothing.
A caller whose argument classes and return usage would still fit the new shape isn’t listed as changing.
Limits
- Only what ran. A caller that never ran in any recorded run isn’t in the list, and neither is the argument class it would have passed. Pair it with a search for a method this important.
- Shape comes from usage. A return value that’s passed along untouched, or stored and read later in a different run, has no messages sent to it in the tree that returned it, so it looks unused.
- App methods only. Messages sent to a returned
IntegerorStringare core methods, and those aren’t recorded. For a return value of one of your own classes, likeEntry, they are.
Related tools
what_calls
Who can call this method. Every caller of a method from real runs, with where and how often. The calls that actually happened, not grep. what_it_calls does the reverse.
See more →observed_shape
The observed contract. The messages your code actually sends to a value, read from real runs. The interface a call depends on, whether or not anyone declared it.
See more →what_it_receives
What a method receives. The observed shape of each argument at a call site. The messages the method actually sends to what it's passed, not only the class it was given.
See more →