LexQLexQ
Back to blog

Config Files, Feature Flags, or Something Else? Managing Business Logic at Scale

A config file holds a discount's rate, and a feature flag can add who gets it. The whole rule needs a rules table, then versions, past-order simulations, and decision records once it changes often and people ask why, built or bought.

Sanghyun Park·June 7, 2026Updated October 11, 202614 min read

A config file can hold a discount's rate, and a feature flag (a switch that turns code on for some users without a deploy) can add who gets it. The whole rule, down to how the discount is computed, needs a rules table, and once the rules change often and people ask why an order got its discount, the table also needs versions, simulations on past orders, and a record of each decision, which you either build or buy.

Take an e-commerce site where VIP customers get 20% off orders of $100 or more. During the holiday sale every order gets 15% off, and the two discounts don't stack: the holiday one wins. Both rates sit in application.yml, and an if in checkout code picks one. Marketing now wants to change these rules without waiting for a deploy. Support wants to know why a VIP customer's $150 order got $22.50 off when the VIP discount would have been $30.

Where each option fits

Pick it whenA change goes live by"Why did this order get 15%?" is answered byYou also maintain
Config fileThe rates change a few times a year and the conditions almost neverA deploy (or a config refresh)The code deployed that day, plus your logsNothing new
Feature flagA promotion goes to some customers for a few weeks, then the flag is removedChanging the flag, or turning it on for a share of users firstThe flag's change history plus the checkout codeRemoving old flags
Rules table you buildMarketing edits the conditions, but there are few rules, one engineer owns them, and nobody asks why yetEditing a rowWhatever your code loggedAn evaluator that runs the stored conditions and an admin screen, then versions, simulations, and records once the rules change often and people ask why
A rules service (LexQ, which publishes this blog)Marketing changes conditions often, promotions overlap, and support or finance ask whyNo app deploy: a new rule version is deployed in LexQ, at once or on a schedule, after simulating it on past ordersThe decision record LexQ saved for that orderThe rules, one API call, and storing each order's traceId (its decision record's ID)

Each row down takes more out of code: first the rate, then who gets it, then the whole rule with its versions and records. Stop at the first row whose "Pick it when" describes your store, and read its last column as the work that comes with it.

A config file holds the rate

application.yml is a fine home for the two rates, because they are plain values. The conditions around them stay in the code that reads them.

// application.yml holds the two rates. Which one applies is decided here.
double rate = 0;
if (campaigns.isActive("HOLIDAY")) {
    rate = discounts.holidayRate();     // 0.15
} else if (customer.tier() == Tier.VIP && order.subtotal() >= 100) {
    rate = discounts.vipRate();         // 0.20
}

Changing 0.20 to 0.25 is a one-line config edit, and the new rate usually reaches checkout only with a deploy. A Spring app can skip that deploy if the rates live on a config server such as Spring Cloud Config. Beans marked @RefreshScope pick up new values when the app's refresh endpoint is called. Moving the $100 threshold into config works the same way. Adding a condition, or changing which discount wins during the sale, still means changing the Java code, and marketing waits for that release.

To answer support, an engineer has to open the code. They find what was deployed on the day of the order, check that the HOLIDAY branch came first, and confirm from the logs that the sale was running that day.

If the rates change a few times a year and the conditions almost never do, stop here. Once marketing wants to add conditions or change which discount wins with each campaign, there are several ways to get rule changes out of the deploy pipeline, each with its own cost.

A feature flag adds who gets it

A feature flag is still an if in checkout code. The difference is where its answer comes from: the flag tool supplies it, and someone can change it there without a deploy. That answer can be more than on or off. In LaunchDarkly a flag can return a string, a number, or JSON, and Unleash calls the returned value a variant and lets it carry a string, JSON, CSV, or number payload. Both tools can also compare numbers you send with the request: LaunchDarkly in targeting rules, Unleash in strategy constraints. So checkout can send the customer's tier and the order subtotal, and a flag can return 0.20 for a VIP order of $100 or more.

The arithmetic still lives in checkout code: the flag hands back a rate, and the code turns it into a discount. With the holiday promotion as a flag of its own, checkout code picks between the two. With one flag, LaunchDarkly checks its rules from top to bottom, so putting the holiday rule above the VIP rule lets the flag pick.

Either way, support's answer is split across two systems. The flag tool's change history (LaunchDarkly) or event log (Unleash) shows who changed which flag and when. LaunchDarkly's SDKs can also report which rule produced each value, in what LaunchDarkly calls an evaluation reason. To tie that reason to an order, checkout code has to save it with the order. The reason names only that rule, not a rule that also matched further down the list. The arithmetic is in the checkout code deployed that day. With two flags, so is the choice of which one wins.

Pete Hodgson's 2017 article Feature Toggles sorts flags by what they are for. Release toggles let unfinished code ship switched off, and they "should generally not stick around much longer than a week or two." Toggles that open a feature to certain users can stay for a very long time. Either way, he writes, savvy teams view toggles as "inventory which comes with a carrying cost." A VIP discount never ends, so as a flag it would be inventory you carry for good, with the arithmetic still in checkout code.

Where a flag does fit is a promotion you release to some customers first and remove when it ends. LaunchDarkly calls that a percentage rollout, and Unleash calls it a gradual rollout.

Everything this section says about the two tools comes from the pages below, read on October 4 to 6, 2026.

A rules table holds the whole rule

Moving the rule itself into data means a rules table with columns for conditions, the action, and priority, plus an admin screen where marketing edits the rows. "VIP, $100 or more, 20% off" becomes one row and "holiday sale, 15% off" another, and an edit to either row goes live without a deploy.

Two rows and an admin screen are the quick part. These pieces come after them:

  • An evaluator that reads the stored conditions and runs them safely
  • An overlap rule for when the VIP row and the holiday row both match
  • Versions: the old value, who changed it, and a one-step way back
  • A pre-release simulation of what a new holiday rate would have done to last month's orders
  • A decision record of which rows matched an order, which one won, and which version was live

At a previous job I developed and maintained a setup like this myself from 2022 to 2025. Its rows were health-alert rules, each judging a blood pressure or glucose reading normal, abnormal, or dangerous. I built the evaluator with a Java library. It worked fine, and it ran rules in priority order. But when two rules matched, there was no way to make only one of them apply. There were also no versions, no simulation, and no decision record. Later we needed things like audit features, so I added columns like updated_at and updated_by to record who last changed each row and when. I kept adding things for other features too, and the system grew. Others found it hard to touch, and I ended up being the only one working on it. When I left I handed it over, but the people who stayed seemed to have trouble using it.

With all five written and running, the checkout team runs a small rule engine for two discounts. If you need an engine anyway, you can build it yourself on a library, or you can hand it to a cloud service. To decide between the two, start with what running Drools in-house costs compared with a cloud rule engine.

The same order in LexQ

LexQ is the rules service in the table's last row. It is a Decision Operations Platform that stores rules like these two discounts as data, runs them, and records the decision for every order. A separate guide covers what a Decision Operations Platform takes on and when a team needs one.

Rules sit in a policy group, LexQ's container for one set of rules, such as checkout discounts. Every change to the rules is a new version of the group: a draft edited in the LexQ console or through the API, then published, which locks it. Deploying a published version puts it live from checkout's next call, and the application is not redeployed.

The group gets two versions. The everyday version has one rule, VIP 20%. The holiday version is a clone of it with Holiday 15% added, a rule that matches every order. It puts the two rules in a mutex group, a setting that lets at most one of its rules apply to an order, and ranks the holiday rule first.

Marketing sets the holiday version's effective window to the sale's first and last day and schedules it, which sets a deploy for the moment the window opens. The scheduler that runs the deploy checks once a minute, so the holiday version can go live up to about a minute late. The end needs no such wait. On every call, LexQ checks the live version's window. Once it has closed, the call falls back to the newest published version whose window is still open, here the everyday version. That check can't bring the start forward: it only falls back from the live version, and the holiday version isn't live until the scheduler deploys it. On the scheduler's next run, LexQ marks the holiday version expired and deploys the everyday version again. The deployment history records both deploys as made by System.

Checkout no longer decides whether the sale is on. It sends two facts (the inputs the group's rules read), the customer's tier and the order subtotal, to the group's ID:

curl -X POST https://api.lexq.io/api/v1/execution/groups/{groupId} \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "facts": {
      "loyaltyTier": "VIP",
      "purchaseSubtotalUsd": 150.00
    }
  }'

Here is the response for a VIP customer's $150 order during the sale. The IDs are shortened, since yours will be different. executionTraces, which records whether each rule's condition matched, is collapsed to [ ... ] because support's question doesn't need it:

{
  "result": "SUCCESS",
  "data": {
    "traceId": "...",
    "inputFacts": {
      "loyaltyTier": "VIP",
      "purchaseSubtotalUsd": 150.00
    },
    "mutatedFacts": {
      "purchaseSubtotalUsd": 127.50
    },
    "generatedVariables": {
      "purchaseSubtotalUsd__delta": -22.50
    },
    "executionTraces": [ ... ],
    "decisionTraces": [
      {
        "ruleId": "90cda54b-...",
        "ruleName": "Holiday 15%",
        "policyGroupId": "30f577e3-...",
        "policyVersionId": "3a823789-...",
        "status": "SELECTED",
        "reasonCode": "FINAL_WINNER",
        "reasonDetail": null
      },
      {
        "ruleId": "944329a0-...",
        "ruleName": "VIP 20%",
        "policyGroupId": "30f577e3-...",
        "policyVersionId": "3a823789-...",
        "status": "BLOCKED",
        "reasonCode": "MUTEX_PRIORITY_LOST",
        "reasonDetail": "Winner=[Holiday 15%], Strategy=HIGHEST_PRIORITY"
      }
    ]
  }
}

The second entry in decisionTraces is support's answer. The VIP rule matched the order, lost to the holiday rule on priority, and reasonDetail names the winner. The first entry is the rule that applied. It rewrote purchaseSubtotalUsd itself: mutatedFacts shows the $127.50 it left, and purchaseSubtotalUsd__delta the $22.50 it took off. LexQ adds a __delta entry like that to generatedVariables by itself for every fact a rule changes, so checkout can read the discount there. Both entries carry the holiday version's ID in policyVersionId, so the record also shows which version was live. After the sale ends, the same order runs on the everyday version and gets VIP 20%: $30.00 off, for a $120.00 subtotal.

To find this record later, checkout stores the traceId with the order, and support searches that ID on the Executions screen of the LexQ console. LexQ writes the record as part of the decision. If it can't save the record, the call fails with AN-029, which the error reference says to retry, and no discount comes back without a record. If retries keep failing, whether to take the order without the discount or hold it is checkout's call. Deleting a version that has decision records fails with AN-028, so the version a trace points to stays readable.

Suppose that partway through the sale, marketing decides VIP customers keep their 20%. That takes a new holiday version: clone the live one, swap the two priorities, and enter the same window again, since a clone doesn't copy the window. Before it goes live, an Impact Simulation runs the new version against orders LexQ has already decided, or against a CSV or JSON file of past orders, and compares the results with the version live today. The Metric Target Fact is the fact whose value the simulation compares between the two versions. Set it to purchaseSubtotalUsd, which holds the discounted subtotal once the rules run. The simulation then counts the orders whose total would change, splits them into increases and decreases, and shows the largest single move. Here the VIP customers' holiday orders show up as decreases, because they now get 20% off. Testing a rule change before deploy with Impact Simulation walks through that check step by step. Deploying the swapped version is one action in the console. If it costs more than expected, one rollback brings back the holiday-first version, in the console or with the LexQ CLI's lexq deploy rollback. The rollback goes into the deployment history as a record of its own.

When you don't need LexQ

If marketing changes the VIP rate a couple of times a year and never changes the $100 threshold or which discount wins, keep the rate in application.yml and deploy. That is cheaper than every other row in the table.

If the holiday sale is a one-time promotion you will delete in January, make it a flag.

If marketing does need to edit the conditions, but the discounts stop at these two, one engineer owns them, and support never asks why an order got its discount, build the table. At two rows it stays small enough for one person to own.

→ Start free at lexq.io

Move one rule out of your code.

Free to start, no credit card. Send facts, get back a result and the reasoning.

Start Free