I'll Be Backend
Principles · Python

KISS: replace a rule engine with plain code

Turn a clever rule engine back into code you read top to bottom.

15 steps4 chapters~5 min at 1.25×
Language
JavaKotlinC#C++PythonRubyPHPTypeScriptJavaScriptGoRustSwift
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Python video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–3

Refactor

Steps 4–12

Payoff

Step 13

Recap

Steps 14–15

The code: before and after

Before

Rule = Callable[[Ticket, float], float]

RULES: dict[str, Rule] = {
    "base": lambda t, f: t.hours() * 3.0,
    "cap": lambda t, f: min(f, 20.0),
    "grace": lambda t, f: 0.0 if t.minutes <= 15 else f,
}


def fee(t: Ticket, plan: list[str]) -> float:
    f = 0.0
    for name in plan:
        f = RULES[name](t, f)
    return f


def charge(t: Ticket) -> float:
    return fee(t, ["base", "cap", "grace"])

After

HOURLY_RATE = 3.0
DAILY_CAP = 20.0
GRACE_MINUTES = 15
EV_FREE_HOURS = 2


def charge(t: Ticket) -> float:
    if t.minutes <= GRACE_MINUTES:
        return 0.0
    hours = t.hours()
    if t.electric:
        hours = max(0, hours - EV_FREE_HOURS)
    return min(hours * HOURLY_RATE, DAILY_CAP)

Transcript, step by step

Problem

Step 1: A rule engine 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 dict 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 every check, and then it 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 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, 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 dict anymore, so nothing reads those three rules.

Step 9: Delete the registry Remove Dead Code

The type alias and the dict of lambdas have no users left, so they go. 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 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. 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 dict 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 engine was.

Step 15: When to reach for it Takeaways

Unwind 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 dict, a loop over steps that never change, and code you have to run in your head to read.