Building a Dynamic Feature Flagging and Entitlements Engine for Subscription Tiers
Architect a sub-millisecond cloud platform entitlements engine to gate features, manage seat quotas, and handle customer plan overrides with Redis.
In the early days of a cloud platform startup, pricing tiers are hardcoded directly into the application: if user.Plan == "pro" { showAdvancedAnalytics() }.
As your business matures, this hardcoded approach becomes an engineering nightmare. The sales team negotiates a custom enterprise deal granting SAML SSO on a mid-tier plan; product managers want to split a feature into an add-on; and marketing launches a 14-day trial of an advanced workflow. Suddenly, your codebase is littered with dozens of fragile if/else checks, and shipping a pricing update requires coordinated code refactors and database migrations across multiple microservices.
To support rapid commercial iteration without compromising performance, you must build a dedicated dynamic entitlements engine. Because entitlement checks sit in the critical path of virtually every API endpoint and UI render, the engine must evaluate access permissions in sub-millisecond latency (< 1ms) while supporting granular customer overrides and instant tier synchronization.
This guide provides the complete blueprint for architecting a production cloud platform entitlements engine using in-process L1 caching, Redis hashes, and real-time Pub/Sub invalidation.
Distinguishing Feature Flags from Commercial Entitlements
Before designing your system, it is vital to distinguish between two concepts that are frequently conflated:
| Attribute | Operational Feature Flags | Commercial Entitlements |
|---|---|---|
| Primary Owner | Software Engineering / DevOps | Product Management / Sales Operations |
| Purpose | Canary deployments, percentage rollouts, emergency kill switches | Monetization, access gating, tier bundling, quota enforcement |
| Targeting Context | User ID, device type, internal employee flag, percentage | Tenant ID, subscription tier, add-ons, contract overrides |
| Lifecycle | Ephemeral (removed once feature is 100% stable) | Permanent (core to company revenue model) |
| Resolution SLA | < 5 milliseconds | < 1 millisecond (evaluated on every HTTP request) |
While operational flags toggle whether a feature is safe to run in production, entitlements determine whether a specific customer has contractually paid to use it.
Modeling the Entitlement Domain
To keep your system flexible, never bind application code directly to plan names like "Starter" or "Enterprise". Instead, structure your domain into three distinct layers:
[ Subscription Tiers ] [ Capabilities / Features ]
- Starter Plan -----> - audit_logs_export (boolean)
- Pro Plan -----> - saml_sso (boolean)
- Enterprise Plan -----> - max_seats (limit: 50)
- retention_days (limit: 365)
^
|
[ Custom Tenant Overrides ]
(e.g. Acme Corp granted SAML
exception on Pro Plan)
- Features (Capabilities): Atomic units of functionality. Can be Boolean Toggles (
saml_sso,custom_domain) or Numerical Quotas (max_seats,api_rate_limit,retention_days). - Plans (Tiers): Bundles of features and quotas with default values.
- Tenant Overrides: Direct exceptions granted to an individual customer that take precedence over the base plan bundle.
The Evaluation Hierarchy: How Permissions Are Resolved
When your application asks: entitlements.Can(ctx, "saml_sso", tenantID), the engine resolves the answer using a strict fallback hierarchy:
[ 1. Check Tenant Overrides ]
Does an explicit override exist?
| |
YES NO
| |
[ Return Override ] v
[ 2. Check Plan Bundle ]
Is feature included in tenant's tier?
| |
YES NO
| |
[ Return Grant ] v
[ 3. Global Default ]
(Return Deny / Zero Quota)
This hierarchy allows sales teams to enable a single enterprise feature for a prospective client during an evaluation without creating a bespoke subscription plan in Stripe.
Sub-Millisecond Architecture: Two-Tier Caching
Querying PostgreSQL for every entitlement check across thousands of requests per second would saturate your database. The solution is a two-tiered caching strategy:
[ Incoming Request ]
|
[ L1 Cache: In-Process sync.Map ] (Hit Latency: ~400 nanoseconds)
| (Cache Miss ~ 2%)
v
[ L2 Cache: Redis Cluster Hash ] (Hit Latency: ~0.8 milliseconds)
| (Cache Miss ~ 0.01%)
v
[ PostgreSQL Authoritative DB ] (Hit Latency: ~5.0 milliseconds)
1. In-Process L1 Cache
Each web server pod maintains an in-memory sync.Map or local cache (like Ristretto). 98% of requests resolve directly from local RAM with zero network I/O in less than 500 nanoseconds.
2. Redis Central L2 Cache
Entitlements are stored in Redis using a Hash per tenant:
Key: ent:{tenant_id}
Fields:
saml_sso: "1"
max_seats: "50"
tier: "pro"
3. Real-Time Invalidation via Redis Pub/Sub
When a customer upgrades their plan or sales grants an override, how do you prevent stale in-memory caches?
Broadcast an invalidation event over a Redis Pub/Sub channel:
package entitlements
import (
"context"
"fmt"
"sync"
"github.com/redis/go-redis/v9"
)
type Engine struct {
rdb *redis.Client
l1Cache sync.Map // tenantID -> map[string]string
db AuthoritativeStore
}
func (e *Engine) StartInvalidationListener(ctx context.Context) {
pubsub := e.rdb.Subscribe(ctx, "entitlement_invalidations")
ch := pubsub.Channel()
go func() {
for msg := range ch {
tenantID := msg.Payload
// Immediately evict stale local L1 cache on this pod
e.l1Cache.Delete(tenantID)
}
}()
}
func (e *Engine) Invalidate(ctx context.Context, tenantID string) error {
// 1. Delete from Redis L2
if err := e.rdb.Del(ctx, fmt.Sprintf("ent:%s", tenantID)).Err(); err != nil {
return err
}
// 2. Broadcast to all other web server pods
return e.rdb.Publish(ctx, "entitlement_invalidations", tenantID).Err()
}
Whenever an upgrade occurs, every application pod evicts its local cache in under 20 milliseconds, delivering an instantaneous upgrade experience to the user.
Enforcement in Handlers and Middleware
Integrate entitlement checks cleanly into your backend application using route middleware:
func RequireEntitlement(engine *Engine, feature string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenant := GetTenant(r.Context())
if tenant == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
allowed, err := engine.Can(r.Context(), feature, tenant.ID)
if err != nil || !allowed {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":"plan_upgrade_required","feature":"%s"}`, feature)
return
}
next.ServeHTTP(w, r)
})
}
}
Numerical Quota Enforcement with Atomic Redis Counters
Boolean flags (e.g. saml_sso: true) represent only half of the entitlements challenge. The harder problem is managing dynamic numerical quotas:
- A customer is entitled to 50 active user seats; how do you block the 51st invitation under high concurrency?
- A plan includes 100,000 monthly API credits; how do you track balance deductions without locking database rows?
To enforce numerical quotas without querying your relational database, maintain an atomic usage counter alongside the entitlement limit in Redis:
Key: quota:seats:{tenant_id}
Value: 47 (current active seats)
Key: ent:{tenant_id}
Field: max_seats -> 50
When an invitation is issued, execute an atomic Redis Lua transaction that compares the current counter against the maximum entitlement:
-- KEYS[1]: quota:seats:{tenant_id}
-- KEYS[2]: ent:{tenant_id}
-- ARGV[1]: increment_amount (usually 1)
local current = tonumber(redis.call("GET", KEYS[1]) or "0")
local limit = tonumber(redis.call("HGET", KEYS[2], "max_seats") or "0")
if current + tonumber(ARGV[1]) <= limit then
redis.call("INCRBY", KEYS[1], ARGV[1])
return 1 -- Allowed
else
return 0 -- Quota Exceeded
end
Because Redis executes Lua scripts as a single atomic unit, concurrent invitations from multiple administrators can never exceed the customer's paid quota.
Handling Plan Grandfathering and Deprecations
As your cloud platform pricing evolves, you will inevitably retire legacy plans. However, enterprise agreements typically guarantee that early customers retain their original feature set and pricing—a practice known as Grandfathering.
Never mutate existing plan definitions in your database when launching a new pricing tier. Instead:
- Version your plan records:
plan_pro_v1_2024,plan_pro_v2_2026. - Existing customers remain pinned to
plan_pro_v1_2024, preserving their exact capabilities. - New signups are assigned
plan_pro_v2_2026. - If an old customer changes tiers, their subscription transitions to the latest active plan version.
| Architecture Option | Latency | Complexity | Operational Advantage | Primary Limitation |
|---|---|---|---|---|
| Direct Relational DB Queries | 5ms - 15ms | Very Low | Immediate consistency; zero cache invalidation | High database connection pressure; unviable at scale |
| Pure Redis Hashes | 0.8ms - 1.5ms | Medium | Sub-millisecond evaluation; shared across all web pods | Network hop required per check; Redis downtime impacts API |
| Two-Tier (In-Process L1 + Redis L2) | < 500ns | High | Optimal: Zero network I/O for 98% of requests | Requires Pub/Sub invalidation to prevent stale local RAM |
Failure Mode Recovery: What Happens When Redis is Down?
An entitlements engine is critical infrastructure. If your Redis cluster experiences a failover or temporary network partition, your API must not crash for paying customers.
Implement a Fail-Safe Evaluation Strategy:
- L1 Fallback: If Redis is unreachable, continue serving reads from the local in-process
sync.Mapcache, logging a warning. - Circuit Breaker to Authoritative DB: If both L1 and L2 miss and Redis is offline, fall back directly to PostgreSQL using connection timeouts capped at 50ms.
- Optimistic Allow for Critical Features: For core product operations (e.g. logging into the dashboard or viewing existing records), default to PERMIT during total cache collapse. Reserve strict denial only for high-cost billable actions (such as initiating third-party AI inferences or bulk exports).
Summary Checklist for Production
- Decouple Plans from Features: Store features as discrete string capabilities, never hardcoded plan comparisons.
- Enforce Fast L1/L2 Caching: Ensure hot-path entitlement evaluations execute in under 1 millisecond.
- Wire Real-Time Invalidation: Connect billing webhook processors to your Redis Pub/Sub invalidation bus.
- Support Overrides: Allow support and sales teams to configure temporary overrides without code changes.
- Use Atomic Lua for Quotas: Prevent seat count race conditions using atomic Redis comparison scripts.
- Return Upgrade Hints: When denying access, return the specific feature name and an upgrade URL to guide self-serve expansion revenue.
Frequently Asked Questions
What is the difference between a feature flag and an entitlement?
A feature flag is an operational toggle used by developers for canary releases, percentage rollouts, and kill switches. An entitlement is a commercial access right granted to a specific customer based on their paid contract tier.
How do you enforce numerical seat quotas without querying the database?
Store current active seat counts in an atomic Redis counter alongside the maximum entitlement limit. Compare the counter to the limit on user invitations, synchronizing back to the database asynchronously.
How quickly do entitlement changes take effect when a customer upgrades?
Using Redis Pub/Sub cache invalidation broadcasts, all application pods evict their local L1 in-memory cache within 10 to 50 milliseconds of an upgrade.
What should the system do if both L1 and L2 caches miss during an entitlement check?
Fall back gracefully to the authoritative relational database with singleflight deduplication, cache the resolved entitlements, and return the evaluated permission.
How do you structure tier inheritance in the code?
Define a hierarchy tree where the Enterprise tier inherits all features of the Pro tier, which inherits all features of the Starter tier, preventing duplicate feature assignments across plans.
Can enterprise customers have custom feature exceptions outside their tier?
Yes. The entitlements engine must evaluate customer-specific overrides first. If an override exists for that tenant and feature, it supersedes the default tier bundle.