LexQLexQ
Back to blog
saasfeature entitlementplan limitsfeature flagspricing tiers

Feature Entitlement & Plan Limits: Stop Hardcoding Your Pricing Tiers

Plan tiers hardcoded across services turn every pricing change into a deploy. Feature flags are the wrong home for entitlement — and a new plan tier should be a rule change, not a deploy.

Sanghyun Park·July 15, 202610 min read

Your pricing page describes three tiers. Your codebase disagrees.

Somewhere in the API there is an if (plan == PRO). The frontend has its own copy, deciding which buttons to render. The billing webhook re-checks the tier when a subscription changes. A background worker enforces the API call limit with a constant that was correct when it shipped. None of these copies knows about the others, and every one of them is feature entitlement — the answer to what did this customer pay for — welded into application code.

Then product asks for something reasonable: a Team tier between Free and Pro. Or a higher API limit on Pro, because sales keeps losing deals over it. The honest engineering answer is a sprint, because the pricing model is not data anywhere — it is a pattern smeared across four codebases. This post is about why that happens, why feature flags are the tool teams reach for and the wrong one, and what it looks like when entitlement becomes a decision your product team can change without touching a release train.

The tier that lives everywhere

The first version is always innocent. One service, one gate:

public class PlanService {

    // Plan limits — owned by product, shipped by engineering.
    private static final int FREE_MAX_SEATS = 3;
    private static final int PRO_MAX_SEATS  = 25;

    private static final int FREE_API_CALLS_MONTHLY = 1_000;
    private static final int PRO_API_CALLS_MONTHLY  = 50_000;

    public boolean canAddSeat(Account account) {
        int max = switch (account.plan()) {
            case FREE -> FREE_MAX_SEATS;
            case PRO  -> PRO_MAX_SEATS;
            case ENTERPRISE -> account.customSeatLimit(); // negotiated per contract
        };
        return account.seatCount() < max;
    }

    public boolean allowSso(Account account) {
        // SSO is Pro and above — for now.
        return account.plan() == Plan.PRO || account.plan() == Plan.ENTERPRISE;
    }
}

Each constant encodes a real commercial decision. The rot is not in any one line — it is in how the decisions multiply and scatter. The UI needs the seat limit to gray out the invite button, so it gets a copy. The usage worker needs the API cap, so it gets a copy. Then the exceptions arrive, because entitlement in a real SaaS business is never just N tiers:

  • The customers who signed up before the 2025 repricing keep their old limits. That is a signupDate check bolted next to the tier check.
  • One enterprise customer negotiated 40 seats at the Pro price. That is an account-ID special case someone swore would be temporary.
  • Marketing ran a promotion that granted SSO to annual Free accounts for a year. That branch is still there. Nobody remembers whether it is still supposed to be.

Entitlement is tiers times exceptions times time. Hardcoded, that multiplication is a decision tree no one can read in one place — and two questions become unanswerable. Support asks why this Pro account got blocked at 1,000 API calls (a stale constant in the worker, it turns out). Finance asks which accounts had SSO access in March under the promotion. The honest answer to both is git blame across repositories, which is another way of saying nobody knows.

Every pricing experiment is a deploy

The scattering has a second cost, and it is the one that shows up in your growth rate: pricing iteration speed.

Pricing is one of the highest-leverage levers a SaaS company has, and it is the lever product teams touch least — not because they lack ideas, but because every idea is a cross-repo code change. Add a Team tier: touch the API gates, the UI checks, the webhook, the worker, and every stale copy you can find. Raise the Pro API limit for a quarter to see what it does to conversion: same list. Grandfather existing customers through a repricing: same list, plus date logic in every location.

So the experiment gets an estimate, the estimate is a sprint, and the sprint loses to the roadmap. The pricing model freezes — not because it is right, but because changing it costs too much. Meanwhile "what would this change do?" has no place to even be asked. You cannot simulate a limit change against last month's real usage when the limit is a constant in a worker. You ship it and watch the support queue.

Feature flags are the wrong home for entitlement

At this point most teams look at the flag system they already pay for and think: targeting rules, percentage rollouts, an audit log, no deploy needed. Why not put the tiers there?

Because the entitlement vs feature flags question is not about capability — flag tools can technically evaluate plan == "pro". It is about what each thing is. Feature flags exist to decouple deploy from release: ship code dark, turn it on, kill it if it misbehaves, then delete the flag. Tools like LaunchDarkly and Unleash do that job well. Entitlement is a different object with a different lifecycle:

Feature flagEntitlement
Question it answersIs this code path released?What does this customer's plan include?
LifespanTemporary by design — created to be deletedPermanent — versioned, never deleted
OwnerEngineering, on the release scheduleProduct and finance, on the packaging schedule
ShapeA boolean, sometimes with targetingConditions over plan, usage, dates, contracts — plus limit values
When it is wrongYou roll back a releaseYou answer a billing dispute
Record requiredFlag change logPer-decision trace: which rule, which version, why

Put the tiers into flags and every row above bites you. The flags can never be deleted, so the flag dashboard rots into your de facto pricing database — a permanent decision tree living in a tool designed for temporary switches. The limit values (25 seats, 50,000 calls) end up as flag variations or crawl back into code, because a flag's output is a variant, not a computed decision. And when a customer disputes a block from six weeks ago, a flag evaluation log tells you which variant they got, not which commercial rule produced it or what the plan included that day.

There is a simple tell. If deleting the flag would break billing, it was never a flag. It is entitlement wearing a flag's clothes.

Flags keep doing their job in this story — gating code readiness while a feature is being built. What they should never gate is plan access.

Entitlement as decisions

The alternative is to treat entitlement the way you already treat the rest of your business-critical state: as data with a lifecycle, outside application code. Every gate — API, UI, webhook, worker — asks the same question of the same place, and the pricing model exists exactly once.

In LexQ, that looks like a policy group where the facts are plan_tier and feature_key, and each boundary is one BLOCK rule carrying its reason:

{
  "name": "Block: sso requires pro",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "feature_key",
        "operator": "EQUALS",
        "value": "sso",
        "valueType": "STRING"
      },
      {
        "type": "SINGLE",
        "field": "plan_tier",
        "operator": "NOT_IN",
        "value": ["pro", "enterprise"],
        "valueType": "LIST_STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "feature_key 'sso' requires the pro plan or above"
      }
    }
  ],
  "isEnabled": true
}

The default is allow: a request that matches no rule proceeds, and the denial is the thing that gets written down. Now walk the earlier list of "a sprint each" changes:

  • A Team tier is adding "team" to the entitled list of each rule that should include it — in a draft version, reviewed as one change, with the full before/after visible.
  • Grandfathering is a fact, not a special case: a signup_before date or a grandfathered flag in the input, and one rule that reads it. Every grandfathered account's answer is deterministic and shows up in its trace.
  • The negotiated enterprise exception is a rule you can point to in a contract dispute, not an account ID buried in a worker.

Seat counts and API-call caps — the limit side of plan limits — follow the same shape with a numeric fact and a threshold instead of a tier list. The full blueprint for the feature side, including the fail-closed catch-all that stops an unmodeled feature from becoming a silent free grant, is in the pattern: Centralizing Plan-Tier Feature Entitlement with BLOCK Rules.

Product changes the plan. Your pipeline ships code.

LexQ manages entitlement as decisions, not flags — so a new plan tier is a rule change, not a deploy. Not a blind one, either. This is where the decision framing pays for itself twice.

First, before the change ships, you can finally ask "what would this do" and get a number. Duplicate the live version, make the edit, and run an Impact Simulation against last month's real requests: how many denials flip to grants under the Team tier, whose usage would newly clear the raised Pro limit, and — the part that catches typos — whether any rule you did not touch moved at all. Test the change on real traffic before a single customer sees it.

Second, after it ships, every answer leaves a record. A denial does not vanish into a log line; it comes back in the response, with its reason:

{
  "result": "SUCCESS",
  "data": {
    "traceId": "7d92c4a1-...",
    "inputFacts": {
      "plan_tier": "free",
      "feature_key": "sso"
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "is_blocked": true,
      "block_reason": "feature_key 'sso' requires the pro plan or above"
    },
    "executionTraces": [ ... ],
    "decisionTraces": [
      {
        "ruleName": "Block: sso requires pro",
        "status": "SELECTED",
        "reasonCode": "FINAL_WINNER",
        "reasonDetail": null
      }
    ]
  }
}

block_reason is already the upsell copy for your paywall. The trace is the answer to the billing dispute — which rule, which version, which inputs, at what time — recorded when the decision was made, not reconstructed six weeks later. Understand every denial the way you would want to understand a failed payment.

The change itself goes through LexQ's own versioned lifecycle — draft, simulate, publish, deploy — and lands atomically on all traffic, because two accounts on the identical plan must never get different answers from a percentage rollout. Your application release train never hears about any of it. Deploy code when the code changes; deploy rules when the pricing changes.

When you don't need LexQ

Two honest cases.

If you sell one plan — or a free product with no paid tier — you have no entitlement matrix, and standing up machinery for it is pure cost. A constant is correct. Come back when the second tier exists.

If you have two stable tiers, the differences are a handful of feature booleans, and packaging changes about once a year, then the flag tool you already run — or a small constants file with one owner — is the right amount of machinery, even though it is not what flags are for. The signal to move is concrete: pricing experiments queued behind deploys, grandfathering and contract exceptions multiplying through your gates, or one billing dispute you could not answer from a record. Two of those, and the entitlement has outgrown the code it lives in.

Flags gate releases. Entitlement is the contract — give it a system of record.

Change a plan limit without a deploy — start free at lexq.io

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