I'll Be Backend
Principles · Go

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×#kiss#simplicity#over-engineering#inline function
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Go 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

type Rule func(t Ticket, fee float64) float64

var rules = map[string]Rule{
	"base": func(t Ticket, f float64) float64 {
		return t.Hours() * 3.0
	},
	"cap": func(t Ticket, f float64) float64 {
		return min(f, 20.0)
	},
	"grace": func(t Ticket, f float64) float64 {
		if t.Minutes <= 15 {
			return 0
		}
		return f
	},
}

func fee(t Ticket, plan []string) float64 {
	f := 0.0
	for _, name := range plan {
		f = rules[name](t, f)
	}
	return f
}

func Charge(t Ticket) float64 {
	return fee(t, []string{"base", "cap", "grace"})
}

After

const (
	hourlyRate   = 3.0
	dailyCap     = 20.0
	graceMinutes = 15
	evFreeHours  = 2
)

func Charge(t Ticket) float64 {
	if t.Minutes <= graceMinutes {
		return 0
	}
	hours := t.Hours()
	if t.Electric {
		hours = max(0, hours-evFreeHours)
	}
	return min(hours*hourlyRate, dailyCap)
}

Transcript, step by step

Problem

Step 1: A rule engine for one fee Before

This code 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 function stored in a map under a name, and fee runs whichever names it's given. Charge passes the plan, a slice 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. A typo like grase still compiles, and then the map hands back a nil function that panics at the exit barrier. And a Go map keeps no order, so the rule that ev goes before cap lives only in that slice and 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 slice. So the slice 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 function 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 because Go has no ternary, it comes in as an if that 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 Rule type and the map of functions have no readers left, so they go. What's left is a few 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 body moves into Charge, and the file 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 function 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.