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
typealias Rule = (Ticket, Double) -> Double
object ParkingFee {
private val RULES: Map<String, Rule> = mapOf(
"base" to { t, _ -> t.hours * 3.0 },
"cap" to { _, f -> minOf(f, 20.0) },
"grace" to { t, f -> if (t.minutes <= 15) 0.0 else f },
)
fun fee(t: Ticket, plan: List<String>): Double {
var f = 0.0
for (name in plan) f = RULES.getValue(name)(t, f)
return f
}
fun charge(t: Ticket): Double {
return fee(t, listOf("base", "cap", "grace"))
}
}After
object ParkingFee {
private const val HOURLY_RATE = 3.0
private const val DAILY_CAP = 20.0
private const val GRACE_MINUTES = 15
private const val EV_FREE_HOURS = 2
fun charge(t: Ticket): Double {
if (t.minutes <= GRACE_MINUTES) return 0.0
val freeHours = if (t.electric) EV_FREE_HOURS else 0
val hours = maxOf(0, t.hours - freeHours)
return minOf(hours * HOURLY_RATE, DAILY_CAP)
}
}Transcript, step by step
Problem
Step 1: A rule engine for one fee Before
This object 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 map 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 rule map 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. The map takes any string as a key, so a typo like grase still compiles, and then it throws 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 rule map 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 a lambda that we could have written in place. So the base lookup becomes its body, hours times three.
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 map anymore, so nothing reads those three rules.
Step 9: Delete the rule map Remove Dead Code
The type alias and the map of lambdas have no readers left, so they go. What's left is four 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 two lines move into charge, and the object is down to one function.
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. Electric cars get two free hours, and the billable hours are the ticket's hours minus those, never 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 map under a string. A loop ran them in whatever order the plan in charge happened to list. On the right, it's one function, and you read the price from top to bottom. It even has the electric car rule, and it's still shorter than the rule map was.
Step 15: When to reach for it Takeaways
Inline an abstraction 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 map, a loop over steps that never change, and code you have to run in your head to read.