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–11Payoff
Step 12Recap
Steps 13–14The code: before and after
Before
final class ParkingFee
{
/** @var array<string, Closure(Ticket, float): float> */
private array $rules;
public function __construct()
{
$this->rules = [
'base' => fn($t, $f) => $t->hours() * 3.0,
'cap' => fn($t, $f) => min($f, 20.0),
'grace' => fn($t, $f) => $t->minutes <= 15 ? 0.0 : $f,
];
}
/** @param list<string> $plan */
public function fee(Ticket $t, array $plan): float
{
$f = 0.0;
foreach ($plan as $name) {
$f = ($this->rules[$name])($t, $f);
}
return $f;
}
public function charge(Ticket $t): float
{
return $this->fee($t, ['base', 'cap', 'grace']);
}
}After
final class ParkingFee
{
private const HOURLY_RATE = 3.0;
private const DAILY_CAP = 20.0;
private const GRACE_MINUTES = 15;
private const EV_FREE_HOURS = 2;
public function charge(Ticket $t): float
{
if ($t->minutes <= self::GRACE_MINUTES) {
return 0.0;
}
$hours = $t->hours();
if ($t->electric) {
$hours = max(0, $hours - self::EV_FREE_HOURS);
}
return min($hours * self::HOURLY_RATE, self::DAILY_CAP);
}
}Transcript, step by step
Problem
Step 1: A rule engine for one fee Before
This class 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 an arrow function stored in an array 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 rules array 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 PHPStan, and then PHP crashes at the exit barrier because there's nothing under that key to call. 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 rules array is gone, the electric car rule goes back in as plain code.
Refactor
Step 4: There is only one plan Remove Parameter
Fee takes a plan as a parameter, but only one caller passes one, and it's 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 loop 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 an arrow function 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 grace, delete the array Inline Function
Grace is the last one, and it zeroes the fee for stays of fifteen minutes or less. Now nothing reads the rules array, and PHPStan rejects a private property nobody reads, so the array and its constructor go in the same step. What's left is four lines of arithmetic you can read without running anything in your head.
Step 9: 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 10: 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 class is down to one method.
Step 11: 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 12: 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 13: Where we started, where we ended Before / after
On the left, every rule was an arrow function filed in an array under a string. A loop 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 rules array and its loop were.
Step 14: 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 an array, a loop over steps that never change, and code you have to run in your head to read.