I am building the promocode engine again.
This is the second time. The first was in Node.js, at a previous company, on top of a small library called business-rules. The engine worked. I thought the shape was brilliant.
I am building it again in Go, from scratch, and the shape is exactly the same.
The first time
The first promocode engine was an AND/OR decision tree. A rule had conditions in groups, groups nested inside groups, leaves that compared a fact to a value. A rule was data in the database – a flattened tree stored across MySQL rows – but it evaluated against facts that had to be computed live.
The clever part was the evaluator. A fact like topup_count_30d was not stored. It could not be – the number changed continuously. So the engine let you register a function that computed the fact at evaluation time. Node’s async callbacks made this natural: you passed the engine a function, the engine called it while walking the tree, the engine handed the result to the next predicate.
“Apply this promo if the user has fewer than 5 topups in the last 30 days” becomes a rule plus a function. The rule says topup_count_30d < 5. The function goes and counts. The engine binds them at check time.
I thought the shape was brilliant. I still think so. A rules engine that only compares stored values is a filter. A rules engine that runs a function to get a value is a small language with a runtime.
The second time
Two years later, a different company, a different scale, Go. The engine is a standalone service inside a larger platform – promo alongside fraud, alongside growth. The three of them share caches, a user fact store, a notion of “this call is part of this user’s session.” Promo is never alone in production; when a promocode gets applied at checkout, fraud has already run and growth is about to get a signal. Bundling them was not an organizational choice. It is the natural shape of anything that is “a condition on a user, right now.”
This is a rewrite, not a port. Go is the way forward in 2017 for us – the rest of the platform is moving there, the team has hit its stride with it, I am a better engineer than I was two years ago. Rewriting costs weeks up front and buys a better fit with everything around it.
The tree is the same tree. AND and OR, nested, leaves that compare a fact to a value. The evaluator is the same evaluator – facts are functions that compute at check time.
What the language changes
The Go version has types on the rule nodes. A predicate knows what kind of value it expects; a fact declares what it returns. A malformed rule refuses to compile into a tree. In Node, a malformed rule ran and failed mid-evaluation, once a user hit it.
A fact in Go is a function with a signature:
type Fact func(ctx context.Context, req EvalRequest) (interface{}, error)
var TopupCount30d Fact = func(ctx context.Context, req EvalRequest) (interface{}, error) {
return topups.Count(ctx, req.UserID, 30*24*time.Hour)
}
The engine stitches facts and rules together at evaluation time, the same way Node did. The shape of the code is different. The shape of the engine is the same.
What stayed
The decision tree, as a data structure with a recursive evaluation. Eligibility as the hard part – “does this user qualify right now.” The runtime evaluator pattern. The flattening of the tree into storage, a different schema but the same idea.
What did not carry over is the specific async-callback shape of Node. In Go, Fact returns a value and an error; the evaluator handles concurrency with context and goroutines where it needs to. The mechanism is different. The abstraction is not.
What changed
This time I am not building it alone. The team has engineers whose opinions are better than mine in several places. I am directing more than I am typing, and the result is tighter for it. The scale is larger, and the engine sits next to systems that were not there the first time. A campaign during a sale event can involve tens of thousands of evaluations per second; the engine was never asked to be fast in the Node version.
The abstraction that survives a rewrite is a good abstraction. The AND/OR tree with a runtime evaluator is one. I did not invent it; business-rules did not invent it either. What I can say is I have now built it twice – once myself, once directing a team. In two languages, at two scales. The tree is the same.
That is what “the shape was brilliant” means. Not that the code was clever – though the Node version was. The primitive is still standing the second time you reach for it.
