Learning Scala: Configuration Is Code with No Deploy Gate

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.

New to this series?

Catch up on earlier posts to follow along with the Functional Programming Isn’t Just for Academics series:

Each post in this series explores how teams use Scala to build applications that stay clean, testable, and easy to scale.

Configuration Is Code with a Weaker Deployment Process

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.

What the Admin Panel Rate Gave Up by Not Being 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 being 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.

Type the Rate So It Cannot Hold Nonsense

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.

scala
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 percent. 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.

Model the Rule, Not Just the Operand

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.

scala
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, built the same way you would model business states anywhere else in the system. 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

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.

scala
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 Is the Boundary Between Draft and Behavior

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.

scala
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.

A Typed Configuration Becomes a Function You Can Read

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

scala
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.

Correctness Has to Be Built In Where the Value Is Born

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.

This is Part 20 in an ongoing series, covering why configuration is code with a weaker deployment process and how to type and validate it before it can reach production, following Part 19's look at naming an unclear business rule with DDD, BDD, TDD, and property based testing. Read "Naming the Business Rule Your Tests Miss"

Frequently Asked Questions

Why is configuration treated differently from code, and why is that dangerous?

Configuration gets called settings or data instead of code, so it skips the protections code gets: types, review, tests, and a deploy gate. But configuration determines behavior just like code does. A tax rate stored as a free text field can accept a percent where a fraction was expected, and nothing stops that value from reaching production the instant someone clicks Save.

What does parse, don't validate mean in the context of configuration?

Parse, don't validate means you transform a raw value once, at the boundary, into a type that cannot represent the invalid case, instead of checking a value and trusting every later reader to re-check it. A function like fromPercent that returns Either[String, Rate] either produces a validated rate or an explanation of why it could not, so nothing downstream ever holds a rate that might secretly be wrong.

How do you stop a bad configuration value, like a mistyped tax rate, from reaching production?

Move validation to the moment configuration is published rather than the moment it is read. A draft can be incomplete or wrong, but a publish step checks it against a typed schema and refuses to commit anything that fails, the same way a type checker refuses to compile code that does not typecheck. The person who typed the bad value finds out immediately instead of two days later.

What is the difference between a configuration draft and a published configuration?

A draft is editable, incomplete, and allowed to be wrong, similar to code before it compiles. A published configuration has passed validation and become a typed value, such as a StoreConfig built from a list of TaxRule values, that the runtime system can safely evaluate. Nothing skips straight from draft to runtime behavior without passing through that gate.

Why use an accumulating validation type like ValidatedNel instead of Either for configuration?

Either short circuits on the first error, which means a form with three bad fields only ever reports the first one. Configuration tends to be wrong in several places at once, so an accumulating type like ValidatedNel collects every error across every field and reports them all in one pass. That respects the person fixing the draft instead of making them resubmit repeatedly to discover each new problem.

How does typing configuration make it easier to test?

Once configuration is a typed value like a TaxRule instead of a raw string, evaluating it becomes an ordinary pure function, such as one that takes a rule and a line total and returns a tax amount. There is no environment to stand up and nothing to mock. You can run a thousand configurations through the function in a test and get the same deterministic answer every time.

 
Next
Next

How to Evaluate Scala Developers for Machine Learning