Config Files, Feature Flags, or Something Else? Managing Business Logic at Scale
Where business logic should live as it grows — config files, feature flags, a database rules table, or a managed decision platform — and when each one stops fitting.
Every backend codebase has a config file that started small. A timeout here, a feature limit there, a few thresholds. Then someone needs the discount rate to depend on the customer's tier. Then the tier logic needs an exception for a holiday promotion. Then finance asks why a specific order got a specific discount last March — and nobody can answer.
That is the moment business logic outgrows the place you put it. Most teams hit it. Few see it coming, because the growth is gradual: each change is small, and none of them feels like the one that tips the file over.
Business logic tends to end up in one of four places — config files, feature flags, a rules table in your own database, and a managed decision platform. Teams usually adopt them in that order, one painful migration at a time. This post is about what each one is good for, and — more useful — where each one stops making sense. By the end you should be able to look at your own logic and name the stage it actually belongs in.
Stage 1: Config files
Config files are for settings: values that change rarely and carry no conditions. A request timeout, a page-size limit, a service URL, a feature's hard cap. If a value is a single number or string that the same rule always reads the same way, a config file is the right home. Stay there as long as you can — it is the cheapest option you will ever have.
The limit shows up the moment the value needs to depend on something.
A config entry is a noun, not a sentence. It can hold vip_discount_rate = 0.20. It cannot hold "VIP customers get 20% off orders over $100, unless a holiday promotion is active, and the discount is capped at $50." That sentence has conditions, and conditions do not live in the config. They live in the code that reads it:
// The config holds the number. The decision still lives here.
if (customer.tier() == Tier.VIP && order.subtotal() >= 100) {
discount = order.subtotal() * config.vipDiscountRate();
}
Two problems compound from here. First, changing the logic — not the number, the logic — means editing code and shipping a release. Second, the config gives you no record of why a past decision happened. It only tells you what the current value is.
The tell that you have outgrown a config file: the branching has moved into your code, and the config is just feeding it constants.
Stage 2: Feature flags
So you reach for feature flags — and this is where a lot of teams take a wrong turn, because flags look like they solve the problem.
Feature flags exist to decouple deploy from release. You ship code dark, flip a boolean, and roll back instantly if it misbehaves. That is an excellent thing to have, and tools like LaunchDarkly and Unleash do it well. For releasing and killing features, flags are the right answer.
They are not a home for business logic, for three reasons.
A flag is a boolean. Business logic is a tree of conditions over many variables. You can model "VIP discount: on/off" as a flag. You cannot model "VIP gets 20% over $100, Gold gets 10%, neither stacks with the holiday promo, capped at $50" as a flag — not without smuggling the actual logic back into the code the flag is supposed to gate. The flag becomes an on/off switch in front of a decision that still lives in your application.
Flags are designed to be temporary. You create one to ship a feature, then you delete it. Business rules are permanent and keep changing. Use a flag system as a rule engine and you stop deleting flags. Your flag dashboard slowly rots into an undocumented decision tree that no one will ever clean up.
The targeting some flag platforms offer ("serve variant B to users in segment X") is real, but it answers who sees a feature. Business logic answers what value a decision should produce, and why. The output of a flag is a variant. The output you actually need is a computed result with a trace.
Feature flags answer "is this on for this user?" Business logic answers "given these facts, what should happen?" Different questions — and the second one is the one eating your codebase.
Stage 3: A rules table in your database
The next move is the right instinct: pull the rules out of code and into data. You build a rules table — a column for conditions, one for the action, one for priority — and an admin screen to edit rows. Now a rule changes without a release, and the logic stops being buried in application code.
This is genuinely better. You have separated rule data from the engine that runs it. For a small, stable set of rules owned by one person, this is often where you should stop.
But understand what you have just signed up to build — and maintain, indefinitely:
- A condition evaluator. Your rows store conditions as… what? JSON? A string in a little expression syntax? Either way, something has to parse and evaluate them safely. You are now writing an expression engine. I have watched more than one team reinvent a slower, buggier version of something that already exists.
- Conflict resolution. Two rules match the same input. Which wins? What about rules that must never both apply? Priority is a column; the logic that honors it is code you own.
- Versioning. Someone edits a live rule. It is wrong. What was the previous version, who changed it, and can you roll back in one step? A bare table gives you none of this unless you build an audit layer on top.
- Testing before production. Before you flip a new rule live, how do you know it will not misfire on real traffic? Most homegrown tables have no answer here. You change the row and watch the dashboards — which means you are testing in production, on real customers.
- A trace for every decision. When compliance asks why this transaction got this outcome six weeks ago, can you reconstruct it? With a mutable table and no per-execution record, the honest answer is usually no.
A rules table is a database schema. A rule engine is that schema plus an evaluator, conflict resolution, versioning, testing, and an audit trail. The schema is the part you can finish in an afternoon. The rest is the part that is never finished — and that you maintain at 2 a.m.
Stage 4: A managed platform
This is the "something else" in the title — and the answer when the work above stops being a side project you can afford.
A managed decision platform keeps rules outside your application code, like the table does, but the platform owns the hard parts: evaluation, versioning, testing against real data, and a trace for every decision. You author rules; you do not build and babysit the engine.
I will be direct about my stake here. I built LexQ because I had watched the "rules table quietly becomes a homegrown engine" path play out one too many times — including across my own six years of Spring backend work. LexQ is the Decision Operations Platform: rules are versioned, tested on real production traffic before they go live, and every execution leaves a full trace. The three things it is built to do map exactly to the gaps in a homegrown table:
- Test rule changes on real production traffic — before they ship. This is Impact Simulation: run a candidate rule set against historical executions and compare the outcomes against the current set.
- Understand every decision with a full trace.
- Deploy with confidence.
A rule is data, not code. You send facts; you get back a result and the reasoning:
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": {
"loyalty_tier":"VIP",
"purchase_subtotal_usd":150.00
},
"context": {}
}'
The response carries the input, the changed values, and — the part a homegrown table almost never has — a per-rule execution trace and the final decision trace:
{
"result": "SUCCESS",
"data": {
"traceId": "2e31f2b8-...",
"inputFacts": {
"loyalty_tier": "VIP",
"purchase_subtotal_usd": 150.00
},
"mutatedFacts": {
"purchase_subtotal_usd": 120.00
},
"generatedVariables": {
"purchase_subtotal_usd__delta": -30.00
},
"executionTraces": [
{
"tenantId": "acme-corp",
"policyGroupId": "01f2b274-...",
"policyVersionId": "a6062090-...",
"ruleId": "3b16ced1-...",
"ruleName": "VIP 20% Discount",
"executedAt": "2026-04-27T09:44:10Z",
"matched": true,
"matchExpression": "(loyalty_tier == 'VIP') && (purchase_subtotal_usd >= 100)",
"inputFacts": {
"loyalty_tier": "VIP",
"purchase_subtotal_usd": 150.00
},
"generatedActions": [
{
"type": "MUTATE_FACT",
"parameters": {
"rate": 20,
"method": "PERCENTAGE",
"refVar": "purchase_subtotal_usd",
"operator": "SUB",
"rounding": { "mode": "HALF_UP", "scale": 2 }
}
}
]
}
],
"decisionTraces": [
{
"ruleId": "3b16ced1-...",
"ruleName": "VIP 20% Discount",
"policyGroupId": "01f2b274-...",
"policyVersionId": "a6062090-...",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
}
]
}
}
That decisionTraces block is the answer to "why did this happen." Six weeks later you do not reconstruct it from dashboards — it was recorded at the moment the decision was made.
This is the column most teams are missing. LexQ is what the "managed" column looks like in practice.
Matching the stage to the scale
None of this is a ladder where the top rung is always best. It is a set of tools, each with a range where it is the right amount of machinery. Here are the four options side by side:
| Config file | Feature flag | Rules table (DIY) | Managed platform | |
|---|---|---|---|---|
| Built for | Static settings | Release toggles | Dynamic rules | Business rules at scale |
| Holds conditions? | No | Barely (boolean) | Yes — you build the evaluator | Yes — built in |
| Change without a release | No | Yes | Yes | Yes |
| Test on real traffic first | n/a | Limited | You build it | Built in (Impact Simulation) |
| Trace per decision | No (git history only) | Flag logs | You build it | Built in (decision trace) |
| Who maintains the engine | You | You / vendor | You | LexQ |
Read down the columns and the boundaries get concrete:
- Config file — settings that rarely change and have no conditions. Do not move until the branching has migrated into your code.
- Feature flags — releasing and rolling back features. Keep them for exactly that. The moment you are tempted to encode business conditions in a flag, that is the signal to stop, not to add another flag.
- Rules table in your database — a handful of rules, one engineer who owns them, changes that are rare, and outcomes nobody audits. A homegrown table is genuinely the right call here.
- Managed platform — rules multiply, more than one person needs to change them safely, you need to test a change before it touches money or users, and someone will eventually ask why a decision happened.
When you don't need LexQ
Two honest cases.
If one config value covers your logic, you do not need any of this. Do not stand up a managed platform — or even a rules table — for a single threshold. A constant in a config file is the correct answer, and adding machinery around it is just cost.
If exactly one engineer owns the logic, changes are rare, and no one audits the outcomes, a rules table in your own database is the right amount of machinery. A managed platform is overhead you cannot yet justify. The signal to move is not "you could" — it is "this keeps breaking, more than one person touches it, and you cannot answer why a decision happened." Until at least two of those are true, stay where you are.
Feature flags for releases. Decision platforms for business logic.
Related reading
Ready to move decisions out of your deploy pipeline?
Free to start, no credit card. Send facts, get back a result and the reasoning.
Start Free