Loading Rules from CSV and a Database Table
The same columns power a rule whether it's parsed from a bundled file at startup or read live from Postgres — the trick is one factory function neither source is allowed to bypass.
The `MaxAgeGuardrail` and `LowMileageBoost` classes from the last lesson prove the `Rule` interface works, but they are useless as a real solution: their thresholds are compiled into the class itself, so changing 2012 to 2013 still means a code change and a deploy — the exact problem this module exists to solve. What actually needs to change is the rule implementation: instead of one bespoke class per guardrail, you write a small number of generic, parameterized rule classes whose thresholds, operators, and target fields arrive from outside — a CSV file, or a database row — at construction time.
A CSV rule file gives each row six columns: `rule_type`, `field`, `operator`, `threshold`, `amount`, and `priority`. `rule_type` selects which `RuleOutcome` the row produces (`GUARDRAIL`, `BOOST`, and so on); `field` names a property on `ValuationContext` such as `mileage` or `modelYear`; `operator` and `threshold` describe the comparison; `amount` only matters for `BOOST` rows, where it carries the dollar adjustment; and `priority` controls evaluation order exactly as it did in the previous lesson. Parsing this shape does not need a full CSV library — a naive `split(",")` is fine as long as you control the file and none of your values legitimately contain a comma, which is a fair assumption for numeric thresholds and short field names.
The interesting design decision is the factory function that turns one parsed row into a live `Rule` instance. `ruleFromRow()` looks up the named field against a small map of selectors (`"mileage" -> { ctx -> ctx.mileage.toDouble() }`), then dispatches on `rule_type` to decide whether to construct a `ComparisonGuardrail` or a `ComparisonBoost`. This is the one place in the whole system that knows the string `"GUARDRAIL"` means "construct this particular class" — every other piece of code just works with the `Rule` interface. Add a fifth outcome type later, and this `when` is the only place you touch.
Bundling rules in a CSV file checked into source control gives you something valuable: every rule change goes through the same PR review, the same CI, and the same git history as any other code change, which matters for a rule set the compliance team may eventually want to audit. The cost is exactly the cost this module set out to remove — changing a threshold still requires a deploy, just a much smaller and safer one than editing a 200-line function used to be. CSV rules make sense for policy that changes rarely but should be reviewed carefully every time it does.
A `pricing_rule` table in the same Postgres database from Module 3 removes that remaining deploy requirement entirely. The row shape is identical — the migration that creates `pricing_rule` mirrors the CSV's six columns column for column — and the loader is a small Spring repository that runs a `SELECT` and maps each `ResultSet` row through the exact same `ruleFromRow()` function the CSV loader calls. That reuse is the whole point: parsing "where did this row come from" and "what does this row mean" are two separate concerns, and only the first one differs between the two loaders.
Reading `pricing_rule` on every single valuation request would be a needless round trip to Postgres for data that changes maybe a few times a day, so in practice you load the active rule set once — at startup, and again on a scheduled refresh — and keep the sorted list in memory for the engine to use, the same in-memory list `RulesEngine` already expects from the CSV loader. Whether that refresh is a simple `@Scheduled` poll or something that invalidates a shared cache in Redis is an infrastructure decision the rules themselves do not need to know about; either way, the engine you build in the next lesson never talks to the database directly.
// Two generic, data-driven Rule implementations, plus the comparison// helper they share -- the parameterized cousins of the hardcoded// MaxAgeGuardrail and LowMileageBoost from the last lesson.private fun compare(actual: Double, operator: String, threshold: Double): Boolean = when (operator) {">" -> actual > threshold"<" -> actual < threshold">=" -> actual >= threshold"<=" -> actual <= threshold"==" -> actual == thresholdelse -> error("Unsupported operator: $operator")}class ComparisonGuardrail(override val name: String,override val priority: Int,private val field: (ValuationContext) -> Double,private val operator: String,private val threshold: Double,private val reason: String,) : Rule {override fun evaluate(context: ValuationContext): RuleOutcome? =if (compare(field(context), operator, threshold)) RuleOutcome.Guardrail(reason) else null}class ComparisonBoost(override val name: String,override val priority: Int,private val field: (ValuationContext) -> Double,private val operator: String,private val threshold: Double,private val amount: Double,private val reason: String,) : Rule {override fun evaluate(context: ValuationContext): RuleOutcome? =if (compare(field(context), operator, threshold)) RuleOutcome.Boost(amount, reason) else null}
Two generic, data-driven Rule implementations plus the comparison helper they share — the parameterized cousins of the hardcoded MaxAgeGuardrail and LowMileageBoost from the last lesson.
import java.io.Fileprivate val fieldSelectors: Map<String, (ValuationContext) -> Double> = mapOf("mileage" to { ctx: ValuationContext -> ctx.mileage.toDouble() },"modelYear" to { ctx: ValuationContext -> ctx.modelYear.toDouble() },"accidentCount" to { ctx: ValuationContext -> ctx.accidentCount.toDouble() },)// The one place that knows how a rule_type string turns into a live Rule instance.fun ruleFromRow(ruleType: String,field: String,operator: String,threshold: Double,amount: Double,priority: Int,): Rule {val selector = fieldSelectors[field] ?: error("Unknown field: $field")val name = "$ruleType-$field-$operator-$threshold"val reason = "$field $operator $threshold"return when (ruleType) {"GUARDRAIL" -> ComparisonGuardrail(name, priority, selector, operator, threshold, reason)"BOOST" -> ComparisonBoost(name, priority, selector, operator, threshold, amount, reason)else -> error("Unknown rule_type: $ruleType")}}// rule_type,field,operator,threshold,amount,priority -- amount is blank/ignored for non-BOOST rows.// Real CSV needs a proper parser for quoting/escaping; this naive split is fine// for our controlled, comma-free values.fun loadRulesFromCsv(path: String): List<Rule> =File(path).readLines().drop(1) // header.filter { it.isNotBlank() }.map { line -> line.split(",").map { it.trim() } }.map { cols ->ruleFromRow(ruleType = cols[0],field = cols[1],operator = cols[2],threshold = cols[3].toDouble(),amount = cols[4].toDoubleOrNull() ?: 0.0,priority = cols[5].toInt(),)}
The factory that turns one parsed row into a live Rule, and the naive CSV loader that calls it. Requires java.io.File, so it does not run in-browser.
import org.springframework.jdbc.core.JdbcTemplateimport org.springframework.stereotype.Repository// Same rows, this time from a `pricing_rule` Postgres table instead of a// bundled file -- see Module 3 for the DataSource/JdbcTemplate setup.@Repositoryclass PricingRuleRepository(private val jdbcTemplate: JdbcTemplate) {fun loadActiveRules(): List<Rule> =jdbcTemplate.query("SELECT rule_type, field, operator, threshold, amount, priority FROM pricing_rule WHERE active = true") { rs, _ ->// The exact same factory the CSV loader calls -- only the row source differs.ruleFromRow(ruleType = rs.getString("rule_type"),field = rs.getString("field"),operator = rs.getString("operator"),threshold = rs.getDouble("threshold"),amount = rs.getDouble("amount"),priority = rs.getInt("priority"),)}}
The same factory reused from a Postgres-backed repository via Spring's JdbcTemplate — only the row source changes, never the parsing logic. Requires a real DataSource/JdbcTemplate bean, so it does not run in-browser.
🧠 Check your understanding
0/1 · 0/1 answered1. Both the CSV loader and the Postgres-backed `PricingRuleRepository` call the same `ruleFromRow(...)` factory to turn a row into a `Rule`. What is the main benefit of sharing that one function between both sources, instead of writing separate row-to-Rule logic for CSV and for the database?