KISS: replace a rule engine with plain code
Turn a clever rule engine back into code you read top to bottom.
Watch the narrated video
Every step
Problem
Steps 1–3Refactor
Steps 4–12Payoff
Step 13Recap
Steps 14–15The code: before and after
Before
module ParkingFee
RULES = {
"base" => ->(t, f) { t.hours * 3.0 },
"cap" => ->(t, f) { [f, 20.0].min },
"grace" => ->(t, f) { t.minutes <= 15 ? 0.0 : f }
}.freeze
def self.fee(t, plan)
plan.reduce(0.0) { |f, n| RULES.fetch(n).call(t, f) }
end
def self.charge(t)
fee(t, %w[base cap grace])
end
endAfter
module ParkingFee
HOURLY_RATE = 3.0
DAILY_CAP = 20.0
GRACE_MINUTES = 15
EV_FREE_HOURS = 2
def self.charge(t)
return 0.0 if t.minutes <= GRACE_MINUTES
hours = t.hours
hours = [hours - EV_FREE_HOURS, 0].max if t.electric?
[hours * HOURLY_RATE, DAILY_CAP].min
end
endTranscript, step by step
Problem
Step 1: A rule registry for one fee Before
This module works out what a driver pays when they leave a parking garage. Parking costs three dollars an hour, it's capped at twenty a day, and the first fifteen minutes are free. Each rule is a lambda stored in a hash under a name, and fee runs whichever names it's given. Charge passes the plan, a list of three strings that decides the order. It works, and whoever wrote it was planning for rules nobody has asked for yet.
Step 2: The new rule has to sit in the right slot Code smell
Now electric cars park the first two hours free. So we add an ev rule that takes six dollars off, and slot its name into the plan. It has to run after base and before cap, because if the cap comes first, a long stay drops from twenty to fourteen. A clever abstraction is machinery more general than the problem, and this registry hides a calculation that always runs the same steps in the same order. You can see it here, in rules looked up by string, here, in the loop that runs them, and here, in the plan that sets their order. To know what one ticket costs, you read three places and run the loop in your head. A typo like grase passes the syntax check, and then Ruby raises a key error at the exit barrier. And the rule that ev goes before cap isn't written anywhere, so it lives only in someone's memory.
Step 3: Back it out, then simplify Plan
Let's back that change out. We'll simplify first, one small step at a time, and every ticket keeps the same price after each one. When the registry is gone, the electric car rule goes back in as plain code.
Refactor
Step 4: There is only one plan Remove Parameter
Fee took a plan as a parameter, but only one caller passed one, and it was always the same list. So the list moves inside fee, and charge passes the ticket and nothing else.
Step 5: Three lines instead of a loop Unroll Loop
The reduce always runs the same three names, so we can write them out. Base, then cap, then grace, and now the order is the order of the lines on screen.
Step 6: Inline the base rule Inline Function
Each lookup hands back a lambda that we could have written in place. So the base lookup becomes its body, hours times three, and because base ignored the starting zero, that line goes too.
Step 7: Inline the cap rule Inline Function
The cap gets the same treatment, and its line now says the fee never goes above twenty.
Step 8: Inline the grace rule Inline Function
Grace is the last one, and it zeroes the fee for stays of fifteen minutes or less. Fee doesn't touch the hash anymore, so nothing reads those three rules.
Step 9: Delete the registry Remove Dead Code
The hash of lambdas has no users left, so it goes. What's left is three lines of arithmetic you can read without running anything in your head.
Step 10: Check the grace period first Guard Clause
Grace ran last, but it throws away everything before it, so it can run first. A short stay returns zero straight away, and every other stay gets hours times three, capped at twenty. Every ticket still gets the same price, and the variable f is gone.
Step 11: charge does the work itself Inline Function
Charge only passed the ticket on to fee, which was one more place to look. So fee's lines move into charge, and the module is down to one method.
Step 12: Name the numbers Replace Magic Number
The string keys were the only names these numbers had, so now the numbers get names of their own. Charge now reads like the price list at the barrier, the grace period first and then the hourly rate up to the daily cap.
Payoff
Step 13: Electric cars, in reading order Payoff
Now the electric car rule goes back in. Billable hours start as the ticket's hours, and electric cars take two off, never going below zero. Constants sit where the string keys were, plain lines sit where the loop was, and the order is the order you read. The discount comes before the cap because it's written above it, so there's no slot to get wrong. The grace check and the other three constants didn't change.
Recap
Step 14: Where we started, where we ended Before / after
On the left, every rule was a lambda filed in a hash under a string. A reduce ran them in whatever order the plan in charge happened to list. On the right, it's one method, and you read the price from top to bottom. It even has the electric car rule, and it's still shorter than the registry was.
Step 15: When to reach for it Takeaways
Replace an abstraction with plain code when it has one user and only ever one configuration. Keep it when the rules really change at runtime, like pricing per garage loaded from config, or rules other teams plug in. You'll spot it by string keys looked up in a hash, a loop over steps that never change, and code you have to run in your head to read.