Skip to main content

The Most Dangerous Code Has a Save Button

· 11 min read
Tony Moores
Founder & Principal Consultant, TJM Solutions

Functional Programming Isn't Just for Academics — Part 20

On a Thursday, someone on the finance-ops team opened the admin panel to update a tax table. A new state rate had taken effect: 7.25%. They found the field, typed 7.25, clicked Save, and moved on to the next row. The system stored tax rates as decimals, so 0.0725 meant seven and a quarter percent. The field took 7.25 at face value. For the next two days, every order shipping to that state was taxed at seven hundred twenty-five percent. The first customer to complain had been charged more tax than the price of the goods.

Nobody deployed anything. No pull request was opened. No test ran. No reviewer looked at it. The most expensive behavior change the business made that quarter went out with less scrutiny than a one-line typo fix in the codebase would have received.

This is not a story about a careless employee. It is a story about a system that decided, somewhere along the way, that this particular kind of behavior change did not count as code, and therefore did not deserve any of the protection we give to code.

Every commerce platform runs on configuration. Tax rules, pricing tables, shipping thresholds, return windows, fulfillment routing, eligibility rules, feature toggles, store policies, marketplace overrides, the thousand small switches in an admin panel that decide what the software actually does for a given customer on a given day. We call it "configuration," and that word does an enormous amount of quiet damage, because it carries an implication: that this is data, not logic. Inert. Safe to change. The sort of thing a non-engineer can edit on a Thursday because it is "just settings."

So configuration gets a different rulebook from the rest of the system. Code goes through types, review, tests, a build, a deploy, a staged rollout. Configuration goes through a text box. Code that returns the wrong number fails a test. Configuration that returns the wrong number fails a customer. And we tell ourselves this is fine, because configuration is "just values," as if a value that decides whether you charge tax in a jurisdiction, and how much, is somehow less load-bearing than the function that reads it.

Here is the uncomfortable fact underneath all of it: configuration determines behavior. That is why it exists. A tax rule is a computation: tax equals line total times rate, unless this category is exempt here, unless this customer has a certificate, unless this jurisdiction has a reduced treatment for this product class. You did not avoid writing that computation by moving the rate, exemption, and jurisdictional rules into a database. You just moved part of the program to a place with no type system, no review gate, and no test suite. Then you let people who have never seen the computation change its behavior in production, instantly, with no way to roll back except to notice the damage and type a different number.

Configuration is not the safe part of the system. It is code with a weaker deployment process, and that makes it dangerous precisely because everyone agreed to stop treating it as code.

Walk through what the admin-panel rate gave up by not being code. It gave up types. The field accepted 7.25 as readily as 0.0725, and it might have accepted -1, 725, or an empty string coerced to zero. There is no notion in a free-text configuration field of "a tax rate is a fraction between zero and, say, a quarter, while the human is going to type a percent and the machine wants a decimal." That unit question is exactly the kind of knowledge that lives in someone's head instead of in a type, and heads have bad Thursdays.

It gave up validation at the moment of authoring. If the value is wrong, nothing objects when it is saved. The wrongness is discovered later, at read time, when a customer's order evaluates the rule and either the business eats the difference or the customer does. It also gave up determinism you can reason about. When configuration is scattered across tables, panels, overrides, and environment-specific defaults, "what tax will this order be charged?" stops being a question you can answer by reading a function. It becomes a question you answer by reproducing production state. The logic is real. It is just smeared across a place you cannot easily test.

None of these are exotic failures. They are the same failures we eliminated from application code decades ago, reintroduced wholesale the moment we relabeled the code as "settings."

The fix is not adding labels to the UI or saying, "be more careful in the admin panel." Carefulness is not a control. The fix is to treat each piece of configuration as what it is: a value with rules about what makes it valid, and to refuse to let an invalid one exist. Start by giving the thing a type that cannot hold nonsense, and that names the unit the human keeps getting wrong.

opaque type Rate = BigDecimal
object Rate:
// The human types a percent. The system stores a fraction.
// 7.25 becomes 0.0725, not 7.25.
def fromPercent(p: BigDecimal): Either[String, Rate] =
if p < 0 then
Left(s"rate cannot be negative: $p%")
else if p > 25 then
Left(s"rate of $p% is implausible. Was a decimal entered as a percent?")
else
Right(p / 100)

The point of fromPercent returning Either is that there is now a single, named place where "is this a valid rate, and in what unit?" is answered, and it answers before the value is allowed to become a Rate. The number 7.25 does not silently become a rate of 725%. It is interpreted as a percent, checked for plausibility, and either becomes a validated fraction or becomes an explanation of why it cannot. There is no third path where a bad value slips through wearing the right type.

This is the discipline sometimes called parse, don't validate. You do not check a value, pass it along, and trust every later reader to re-check it. You transform it once, at the boundary, into a type that cannot represent the invalid case. Code downstream that holds a Rate holds a guarantee, not a hope.

Now model the rule, not just the operand. How a category is taxed in a place is not a number; it is a small piece of behavior with a shape.

enum TaxTreatment:
case Taxable(rate: Rate)
case Exempt(reason: ExemptReason)
case ReducedRate(rate: Rate, reason: String)
final case class TaxRule(
jurisdiction: Jurisdiction,
category: ProductCategory,
treatment: TaxTreatment
)
final case class StoreConfig(
taxRules: List[TaxRule],
freeReturns: Boolean
// ...the rest of the store's typed policies
)

This is configuration as a sealed model. A category is taxable, exempt, or reduced: a closed set of possibilities, each carrying exactly what it needs. Adding a new kind of treatment means adding a case, and every place that computes tax must then account for it. The compiler will not let the new case be silently ignored. The "settings" have become a closed set of well-formed possibilities instead of an open field of strings.

Validate when configuration is authored, not when it is read. The rate that mattered in the Thursday story was wrong the instant it was saved. The system just did not look until a customer made it look. The discipline that prevents this is to move validation to publish time — the moment a human commits a configuration change — and to make publishing a configuration that does not pass validation impossible.

final case class ConfigError(field: String, reason: String)
// Use an accumulating validation result, not a short-circuiting Either.
// A broken draft should report every malformed field it can find.
type ValidationResult[A] = ValidatedNel[ConfigError, A]

def validateRate(field: String, percent: BigDecimal): ValidationResult[Rate] =
Rate.fromPercent(percent)
.left.map(reason => ConfigError(field, reason))
.toValidatedNel

def validate(draft: StoreConfigDraft): ValidationResult[StoreConfig] =
val rules: ValidationResult[List[TaxRule]] =
draft.taxRules.zipWithIndex.traverse { case (raw, i) =>
validateRate(s"taxRules[$i].rate", raw.percent).map { rate =>
TaxRule(
jurisdiction = raw.jurisdiction,
category = raw.category,
treatment = TaxTreatment.Taxable(rate)
)
}
}

rules.map { taxRules =>
StoreConfig(
taxRules = taxRules,
freeReturns = draft.freeReturns
)
}

A draft that fails validate never becomes a published StoreConfig. The finance-ops user who typed 7.25 gets a message at the moment of publishing not a postmortem two days later. The accumulation matters too. Configuration tends to be wrong in several places at once, and a publish step that reports every malformed field at once respects the person trying to fix it. A validation gate that fails one field at a time is not a compiler; it is a tripwire.

Publishing then becomes an explicit boundary. A draft is editable, incomplete, and allowed to be wrong. A published configuration is typed, validated, and safe for the runtime system to consume.

def publish(draft: StoreConfigDraft): IO[ConfigPublishResult] =
validate(draft) match
case Validated.Valid(config) =>
store.commit(config).map(ConfigPublishResult.Published(_))
case Validated.Invalid(errors) =>
IO.succeed(ConfigPublishResult.Rejected(errors.toList))

That shape matters. Rejection is not an exception path nobody planned for. It is a first-class outcome of trying to publish a configuration draft. The system is allowed to say no before the value becomes behavior. That is the same job the type checker has for code: refuse to ship something malformed at the moment of authorship, with a specific reason.

Once a published configuration is a properly typed value, the behavior it drives can be evaluated by ordinary functions.

def taxFor(rule: TaxRule, lineTotal: Money): Money =
rule.treatment match
case TaxTreatment.Taxable(rate) => lineTotal * rate
case TaxTreatment.ReducedRate(rate, _) => lineTotal * rate
case TaxTreatment.Exempt(_) => Money.zero

There is nothing to mock here, nothing to reproduce, no environment to stand up. Given a rule and a line total, the tax is decided the same way every time. You can put a thousand configurations through this function in a test and watch each one behave. The "what will this do in production?" question collapses back into "what does this function return?", which is a question you can answer by reading it.

That is the whole move. The behavior did not get simpler. It got honest about being behavior. It now lives somewhere with a type, a validation gate, and a deterministic evaluator: the three things we would never let important application code go without, and the three things we cheerfully stripped away the moment we called the same logic "config."

The tax rate was always part of the program. It decided what the business charged and what the customer owed. It could be wrong, and it was. The admin panel did not make that behavior safer. It only let this particular piece of behavior skip the type checker, skip review, skip tests, and go straight to production in the hands of whoever had the panel open. Calling it configuration did not make it harmless. These are all the things you would have done with runtime input, and configuration is exactly that: a stored value read at runtime.

No part of the surrounding machinery can hand that safety back after the fact. Monitoring will tell you collected tax spiked; it will not tell you the rate was malformed before a single order ran. A rollback playbook gets you to yesterday's number; it does not stop tomorrow's typo. Audit logs will tell you who clicked Save; they will not tell you why the system allowed the saved value to become nonsense.

Correctness has to be built in where the value is born: typed so it cannot hold nonsense, validated so a bad one cannot be published, evaluated by a function you can actually read. Someone will change that rate again next quarter, and they may never have seen the computation it feeds. Whether their mistake reaches a customer is settled at the one moment we keep insisting is not engineering: the moment they click Save.