A cart hits checkout. One question: does the code SAVE20 apply? The answer has to be yes or no, fast, before the user sees the total.

The simple version: check the code exists, check it hasn’t expired, subtract 20%. The real version: the conditions are a tree, and evaluating it correctly under load is most of the work.

What a promocode engine does

Two things. It decides whether a code applies to a cart, and it applies the discount. The second half is arithmetic – subtract a percentage, cap a total, set a floor. The first half is a rules engine that has to evaluate condition trees built by campaign managers through a CMS.

A single code might carry six conditions: category must be electronics, brand must be in a list of three, user must be a first-time buyer, the cart can’t contain sale items, the code is only valid Tuesday through Thursday, and the order total must be above 500. Each condition is straightforward on its own. Together they form a tree, and the tree has to be correct every time.

Eligibility is the hard part

The operators are boring. EQUALS, IN, GREATER_THAN, CONTAINS, BETWEEN. A fixed set of maybe ten. They haven’t changed since the first version went in.

This is deliberate. The people writing the rules don’t code. They work in a CMS with dropdowns and text fields. The operators need to be something they can reason about – “does the cart contain items from this brand list” is a question a campaign manager asks naturally. If the operator vocabulary grows, it grows because the business needs a new kind of condition, not because the engine got clever.

The complexity isn’t in any single condition. It’s in composition. Conditions combine with AND and OR. They nest. A date-range check might sit inside a user-segment branch – if new user AND (category electronics AND date within campaign window). The tree structure means the engine has to be correct about short-circuit evaluation, or it wastes time checking conditions that can’t change the answer.

type Condition struct {
    Field    string // "category", "brand", "user_type", "order_total"
    Operator string // "EQUALS", "IN", "GREATER_THAN", "BETWEEN"
    Value    string
}

type Node struct {
    Type     string // "and", "or", "condition"
    Children []Node
    Cond     *Condition // set only when Type == "condition"
}

The conditions arrive as structured data from the CMS – JSON, not a DSL. No parser. No syntax errors at checkout time. The campaign manager configures rules in a form. The CMS serializes them. The engine deserializes them and evaluates. Nothing between the form and the checkout path that can be miswritten.

Where the cleverness goes

The engine walks the tree. It prunes aggressively.

A condition node evaluates against the current cart and returns true or false. An AND node short-circuits on the first false child – if the date range excludes the code, don’t bother checking the product category. An OR node short-circuits on the first true child. The evaluation order matters, so the tree is sorted before it’s stored: the cheapest, most discriminating checks run first.

func (n *Node) Evaluate(cart *Cart) bool {
    switch n.Type {
    case "condition":
        return n.evalCond(cart)
    case "and":
        for _, child := range n.Children {
            if !child.Evaluate(cart) {
                return false
            }
        }
        return true
    case "or":
        for _, child := range n.Children {
            if child.Evaluate(cart) {
                return true
            }
        }
        return false
    }
    return false
}

Date-range is cheaper than category-lookup. User-type is cheaper than basket-total. The sort is a static optimization – done once when the campaign is created, paid for one time, then never thought about again. The engine doesn’t reorder at runtime. It trusts the tree as stored.

Where it doesn’t go

There was a temptation to build a rule builder. A visual interface where campaign managers drag conditions and nest them. A DSL they could type directly. A condition language with operator precedence and parentheses.

None of it shipped. The campaign team already had a spreadsheet. They modeled rules there. The CMS turned spreadsheet rows into condition trees. The engine evaluated them. The round-trip was boring, and boring was the point.

Adding a DSL would have meant two things: a parser to maintain, and a new class of bugs where a mistyped rule fails silently at checkout. The structured data format eliminated both. A malformed condition tree fails at CMS save time, with a validation error the campaign manager sees immediately. It never reaches the checkout path.

The checkout path

The engine runs on every cart page load. Every active promocode gets evaluated against the current cart state. For a user with ten items and five active campaigns, that’s five tree walks per page view.

Eligibility results are cached. The cache key is fragile: user ID, cart hash, current time rounded to the minute, and the set of active campaign IDs. A stale eligibility cache is worse than no cache at all – it means a user sees a discount that no longer applies, or misses one they should have gotten. The TTL is 60 seconds. Misses are cheap. Incorrect answers aren’t.

cacheKey := fmt.Sprintf("promo:%s:%s:%s:%s",
    userID,
    cart.Hash(),
    time.Now().Truncate(time.Minute).Format(time.RFC3339),
    strings.Join(activeCampaigns, ","),
)

A miss during checkout is a real evaluation – fresh, correct, paid for with a few milliseconds of CPU. The worst thing the cache can do is save time at the cost of being wrong.

The campaigns pile up

Every marketing initiative adds conditions to the tree. A seasonal sale. A loyalty reward. A partner promotion. The campaign set grows, and with it the number of codes active at any moment.

The engine doesn’t grow with them. New operators are rare – maybe one every few quarters. New evaluation logic even rarer. The thing that lets the engine handle the combinatorial growth is that it stays small. The complexity lives in the data, not the code.

The boring engine is the one still running. The clever one would have been rewritten twice by now.