Modeling a Rule
One interface, one sealed hierarchy of outcomes, and a priority number — that's the entire vocabulary the rest of the decision engine is built on.
Before writing an engine that can evaluate rules, you need a shape for a rule to fit into — a Kotlin type every guardrail, boost, and refusal can implement the same way, no matter how different their thresholds and business justifications are. That shape is deliberately small: an interface with a name, a priority, and a single method that takes the facts about a vehicle and returns whatever this rule has to say about it — or nothing at all, if the rule does not apply. Everything about a rules engine's flexibility comes from keeping this contract this narrow.
`ValuationContext` is that bundle of facts: the VIN, mileage, model year, reported accident count, and the raw number a provider like Black Book already returned. A rule only ever sees this context — it does not reach into the repository, does not call a provider itself, and does not know anything about Postgres, Redis, or HTTP. That isolation is what makes a rule trivial to unit test: construct a `ValuationContext` by hand, call `evaluate()`, and assert on the result, with no database or mock provider anywhere in sight.
The return type is the more interesting design decision. `evaluate()` returns a nullable `RuleOutcome` — null means "this rule has nothing to say about this vehicle," which lets the engine simply skip it. When a rule does apply, it returns one of four sealed subtypes: `Guardrail`, a hard stop that means refuse the vehicle outright; `NoBuy`, a second flavor of hard stop typically reserved for a different business reason (a title problem versus a policy limit, say) but treated identically by the engine; `Boost`, a positive price adjustment with a dollar amount attached; and `NoOffer`, which means keep the vehicle in play but do not let an algorithm price it — send it to a human. Making this a sealed class rather than, say, a plain enum matters because each variant carries exactly the data it needs — a `Boost` needs an amount, a `Guardrail` just needs a reason — while the compiler still forces every `when` over `RuleOutcome` to handle all four cases, or explicitly say it isn't going to.
Notice what these four outcomes are not: there is no generic "adjust price by this delta, positive or negative" type. That is a deliberate simplification for this course, but the same principle applies to a real system — the fewer distinct outcome shapes you allow, the easier the engine that consumes them stays. Every new outcome type you add is a new case every consumer of `RuleOutcome` has to think about, forever.
Priority is what turns a bag of rules into an ordered evaluation. Each rule carries a small integer, and the engine sorts by it before running anything — guardrails and no-buys are given low numbers so they run first, boosts get higher numbers so they run last. This matters because of a rule the business will insist on the first time it comes up in a design review: a hard stop always wins, and it should not waste time or risk a side effect by first computing boosts that are about to be thrown away. An ordered list lets the evaluation loop check for a hard stop after only the highest-priority rules have run, short-circuiting before it ever reaches the boosts sitting later in the list.
The two concrete rules below show how little code an individual rule actually needs once the interface exists. `MaxAgeGuardrail` hardcodes 2012 as a cutoff and returns a `Guardrail` when a vehicle's model year is older than that; `LowMileageBoost` hardcodes 30,000 miles and returns a fixed $500 `Boost` for anything under it. These are intentionally the simplest possible implementations — real thresholds should not live baked into a class like this, which is exactly the problem the next lesson solves by loading the same shape of rule from CSV and database rows instead.
Run the example below and watch the priority ordering do its job: a low-mileage vehicle triggers the boost because none of the higher-priority rules fire first, while a vehicle old enough to hit the guardrail never even reaches the mileage check — the loop prints a message and breaks the moment the guardrail fires. That short-circuit, done here by hand with a `break`, is exactly the behavior the real engine in two lessons from now will implement over an arbitrarily long list of configured rules instead of three hardcoded ones.
data class ValuationContext(val vin: String,val mileage: Int,val modelYear: Int,val accidentCount: Int,val providerValuation: Double,)sealed class RuleOutcome {data class Guardrail(val reason: String) : RuleOutcome() // hard stop: refuse the vehicledata class NoBuy(val reason: String) : RuleOutcome() // hard stop: refuse for a different business reasondata class Boost(val amount: Double, val reason: String) : RuleOutcome() // positive price adjustmentdata class NoOffer(val reason: String) : RuleOutcome() // keep the vehicle, but defer to a human}interface Rule {val name: Stringval priority: Int // lower runs first; guardrails and no-buys should sit near zerofun evaluate(context: ValuationContext): RuleOutcome? // null means "does not apply"}
The vocabulary every rule and every outcome shares: a context of plain vehicle facts, a sealed hierarchy of the four things a rule is allowed to decide, and the interface that ties them together.
// Two concrete rules. Thresholds are hardcoded here on purpose --// the next lesson pulls exactly these numbers out into data.class MaxAgeGuardrail(override val priority: Int = 0) : Rule {override val name = "max-age-guardrail"private val oldestModelYearAllowed = 2012override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.modelYear < oldestModelYearAllowed) {RuleOutcome.Guardrail("Model year ${context.modelYear} is older than $oldestModelYearAllowed")} else null}class LowMileageBoost(override val priority: Int = 10) : Rule {override val name = "low-mileage-boost"private val mileageThreshold = 30_000private val boostAmount = 500.0override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.mileage < mileageThreshold) {RuleOutcome.Boost(boostAmount, "Mileage ${context.mileage} is under $mileageThreshold")} else null}
Two concrete rules built from that interface. The thresholds are hardcoded on purpose — the next lesson pulls exactly these numbers out into data.
data class ValuationContext(val vin: String,val mileage: Int,val modelYear: Int,val accidentCount: Int,val providerValuation: Double,)sealed class RuleOutcome {data class Guardrail(val reason: String) : RuleOutcome()data class Boost(val amount: Double, val reason: String) : RuleOutcome()data class NoBuy(val reason: String) : RuleOutcome()data class NoOffer(val reason: String) : RuleOutcome()}interface Rule {val name: Stringval priority: Intfun evaluate(context: ValuationContext): RuleOutcome?}class MaxAgeGuardrail(override val priority: Int = 0) : Rule {override val name = "max-age-guardrail"private val oldestModelYearAllowed = 2012override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.modelYear < oldestModelYearAllowed) {RuleOutcome.Guardrail("Model year ${context.modelYear} is older than $oldestModelYearAllowed")} else null}class AccidentHistoryNoOffer(override val priority: Int = 5) : Rule {override val name = "accident-history-no-offer"private val accidentThreshold = 2override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.accidentCount >= accidentThreshold) {RuleOutcome.NoOffer("Accident count ${context.accidentCount} requires manual review")} else null}class LowMileageBoost(override val priority: Int = 10) : Rule {override val name = "low-mileage-boost"private val mileageThreshold = 30_000private val boostAmount = 500.0override fun evaluate(context: ValuationContext): RuleOutcome? =if (context.mileage < mileageThreshold) {RuleOutcome.Boost(boostAmount, "Mileage ${context.mileage} is under $mileageThreshold")} else null}fun main() {val rules: List<Rule> = listOf(LowMileageBoost(),AccidentHistoryNoOffer(),MaxAgeGuardrail(),).sortedBy { it.priority }val vehicles = listOf(ValuationContext("VIN-001", mileage = 18_000, modelYear = 2021, accidentCount = 0, providerValuation = 21_500.0),ValuationContext("VIN-002", mileage = 62_000, modelYear = 2009, accidentCount = 0, providerValuation = 6_200.0),)for (vehicle in vehicles) {println("Evaluating ${vehicle.vin} in priority order ${rules.map { it.name }}:")for (rule in rules) {val outcome = rule.evaluate(vehicle) ?: continueprintln(" [${rule.name}] fired -> $outcome")if (outcome is RuleOutcome.Guardrail) {println(" Guardrail hit -- short-circuiting, no further rules evaluated.")break}}}}
A complete, runnable program: the full Rule vocabulary, three concrete rules including a NoOffer, and a hand-rolled loop that sorts by priority and short-circuits the moment a guardrail fires.
Arena IDE🧠 Check your understanding
0/1 · 0/1 answered1. `Rule.evaluate()` returns a nullable `RuleOutcome`, and `RuleOutcome` is a sealed class with four variants (`Guardrail`, `NoBuy`, `Boost`, `NoOffer`) instead of, say, a plain enum with no attached data. What does modeling it this way actually buy you?