| 'per_minute' | 'per_month';
softLimit?: boolean; // Allow exceed with warning vs hard block
upgradePrompt?: string;
}
export interface UserEntitlements {
userId: string;
tier: string;
rules: EntitlementRule[];
usageSnapshot: Record<string, number>; // Cached usage for fast checks
lastUpdated: Date;
}
#### 2. Policy Engine Service
The policy engine evaluates requests against the user's current entitlements and real-time usage.
```typescript
// services/PolicyEngine.ts
import { RedisClient } from './redis';
import { EntitlementRegistry } from './EntitlementRegistry';
export class PolicyEngine {
constructor(
private redis: RedisClient,
private registry: EntitlementRegistry
) {}
async evaluate(
userId: string,
featureKey: string,
cost: number = 1
): Promise<{ allowed: boolean; reason?: string; usage?: number }> {
// 1. Fetch cached entitlements to avoid DB hits
const entitlements = await this.registry.getUserEntitlements(userId);
// 2. Locate rule for feature
const rule = entitlements.rules.find(r => r.featureKey === featureKey);
if (!rule) {
return { allowed: false, reason: 'FEATURE_NOT_FOUND' };
}
// 3. Check usage against limit
const currentUsage = await this.getUsage(userId, featureKey, rule.window);
if (currentUsage + cost > (rule.limit || Infinity)) {
if (rule.softLimit) {
await this.emitWarningEvent(userId, featureKey, currentUsage);
return { allowed: true, usage: currentUsage }; // Allow with warning
}
return { allowed: false, reason: 'LIMIT_EXCEEDED', usage: currentUsage };
}
// 4. Reserve usage (optimistic locking)
await this.reserveUsage(userId, featureKey, cost, rule.window);
return { allowed: true, usage: currentUsage };
}
private async getUsage(userId: string, key: string, window: string): Promise<number> {
const redisKey = `usage:${userId}:${key}:${window}`;
const val = await this.redis.get(redisKey);
return val ? parseInt(val, 10) : 0;
}
private async reserveUsage(userId: string, key: string, cost: number, window: string) {
const redisKey = `usage:${userId}:${key}:${window}`;
await this.redis.incrby(redisKey, cost);
// Set expiry based on window to auto-reset counters
await this.redis.expire(redisKey, this.getWindowTTL(window));
}
}
3. Middleware Integration
Implement middleware in your API gateway or application layer to enforce policies uniformly.
// middleware/entitlementGuard.ts
import { Request, Response, NextFunction } from 'express';
import { PolicyEngine } from '../services/PolicyEngine';
export function entitlementGuard(featureKey: string, costExtractor?: (req: Request) => number) {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user.id;
const cost = costExtractor ? costExtractor(req) : 1;
const policy = req.app.get('policyEngine') as PolicyEngine;
const result = await policy.evaluate(userId, featureKey, cost);
if (!result.allowed) {
// Set headers for client-side handling
res.set('X-Entitlement-Limit', result.usage?.toString() || '0');
if (result.reason === 'LIMIT_EXCEEDED') {
return res.status(402).json({
error: 'PAYMENT_REQUIRED',
message: `Usage limit for ${featureKey} reached.`,
upgradePrompt: 'Upgrade to Pro to increase limits.',
usage: result.usage
});
}
return res.status(403).json({ error: 'ACCESS_DENIED', reason: result.reason });
}
// Attach usage context for downstream services
req.usageContext = result;
next();
};
}
4. Usage Reconciliation Worker
To handle race conditions and ensure eventual consistency, a background worker processes usage events from the event bus and updates the persistent ledger.
// workers/UsageReconciliation.ts
import { KafkaConsumer } from './kafka';
export class UsageReconciliation {
async start() {
const consumer = new KafkaConsumer('usage-events');
consumer.on('message', async (event) => {
const { userId, featureKey, amount, timestamp } = event.payload;
// Idempotent write to persistent store
await this.db.query(`
INSERT INTO usage_ledger (user_id, feature_key, amount, period, created_at)
VALUES ($1, $2, $3, date_trunc('month', $4), $4)
ON CONFLICT (user_id, feature_key, period)
DO UPDATE SET amount = usage_ledger.amount + excluded.amount
`, [userId, featureKey, amount, timestamp]);
// Update cache for next pre-flight check
await this.cache.updateUsage(userId, featureKey);
});
}
}
Rationale
- Redis for Fast Checks: Redis provides sub-millisecond reads for usage counters, essential for maintaining low latency. The
incrby command is atomic, preventing race conditions during concurrent requests.
- Soft Limits: Allowing soft limits with warnings improves conversion rates. Users who hit a soft limit receive a prompt rather than a hard error, reducing friction and increasing upgrade intent.
- Event Sourcing: Decoupling metering from enforcement allows the system to handle burst traffic without blocking user actions. The reconciliation worker ensures billing accuracy without impacting request latency.
Pitfall Guide
1. Hardcoding Tier Logic in Business Services
Mistake: Embedding if (user.plan === 'pro') inside domain services.
Impact: Creates tight coupling. Changing a tier requires refactoring multiple services. Security gaps emerge when UI checks exist but API checks do not.
Best Practice: Centralize all access decisions in the Policy Engine. Business services should request permissions, not check tiers.
2. Ignoring Timezone and Period Boundaries
Mistake: Resetting monthly counters based on UTC midnight without considering user timezone or billing cycle start dates.
Impact: Users lose access unexpectedly or retain access longer than paid, leading to disputes and revenue leakage.
Best Practice: Store billing cycle anchors per user. Calculate window boundaries dynamically based on the user's billing anchor, not global time.
3. Race Conditions in Usage Counting
Mistake: Reading usage, checking limit, and writing usage in separate non-atomic steps.
Impact: Users can exceed limits by sending concurrent requests that all pass the check before the counter updates.
Best Practice: Use atomic operations like Redis INCRBY with pre-checks or Lua scripts to perform check-and-increment atomically.
4. Over-Metering Low-Value Actions
Mistake: Metering every minor UI interaction or background health check.
Impact: Inflates infrastructure costs for metering and creates noise in usage analytics. Users get frustrated by limits on trivial actions.
Best Practice: Define a clear scope for metering. Only meter actions that consume significant resources or have direct business value (e.g., API calls, storage writes, compute jobs).
5. Security by Obscurity
Mistake: Relying on frontend UI changes to hide premium features while leaving backend endpoints accessible.
Impact: Malicious users can bypass UI restrictions and access premium data or functionality.
Best Practice: Implement defense-in-depth. Every endpoint must validate entitlements server-side. UI restrictions are for UX only.
6. Failure to Handle "Freemium Abuse" Patterns
Mistake: Not detecting users who create multiple accounts to bypass limits.
Impact: Skews metrics and increases infrastructure costs.
Best Practice: Implement device fingerprinting, email verification thresholds, and anomaly detection on usage patterns. Rate limit at the IP/device level for anonymous or low-trust actions.
7. Silent Failures on Limit Exceeded
Mistake: Returning generic 500 errors or empty responses when limits are hit.
Impact: Users assume the system is broken rather than realizing they need to upgrade. Churn increases.
Best Practice: Return explicit 402 Payment Required or 429 Too Many Requests with actionable messages and deep links to the upgrade flow.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| MVP / < 10k MAU | Hardcoded RBAC with simple DB flags | Low complexity; fast iteration; overhead of engine not justified. | Low dev cost; high risk of debt later. |
| Growth / 10k-100k MAU | Internal Policy Engine | Balances flexibility and cost; real-time metering prevents cost overruns. | Medium dev cost; high ROI via conversion optimization. |
| Enterprise / Multi-tenant | External Entitlement Service (e.g., Stripe Metering, Custom SaaS) | Reduces maintenance burden; handles complex billing logic; audit compliance. | High subscription cost; lower internal dev load. |
| High-Frequency API | Redis-based Rate Limiting + Async Metering | Minimizes latency; protects backend from abuse; accurate billing via async. | Low latency overhead; infra cost for Redis cluster. |
Configuration Template
Use this JSON template to define entitlement rules for a policy engine. This can be stored in a database or version-controlled repository.
{
"version": "1.0",
"tiers": {
"free": {
"rules": [
{
"featureKey": "api_calls",
"type": "usage_cap",
"limit": 1000,
"window": "per_month",
"softLimit": false,
"upgradePrompt": "Free tier limit reached. Upgrade to increase API access."
},
{
"featureKey": "storage_gb",
"type": "usage_cap",
"limit": 5,
"window": "per_month",
"softLimit": false
},
{
"featureKey": "export_csv",
"type": "feature_gate",
"allowed": false,
"upgradePrompt": "CSV export is available on Pro plans."
}
]
},
"pro": {
"rules": [
{
"featureKey": "api_calls",
"type": "usage_cap",
"limit": 50000,
"window": "per_month",
"softLimit": true,
"upgradePrompt": "You are approaching your Pro limit. Contact support for enterprise options."
},
{
"featureKey": "storage_gb",
"type": "usage_cap",
"limit": 100,
"window": "per_month"
},
{
"featureKey": "export_csv",
"type": "feature_gate",
"allowed": true
}
]
}
}
}
Quick Start Guide
- Initialize Redis: Deploy a Redis instance for usage counters. Ensure persistence is configured for metering accuracy.
docker run -d -p 6379:6379 --name freemium-redis redis:7-alpine
- Deploy Policy Engine: Run the Policy Engine service. Configure it to load entitlement rules from your configuration source.
npm run build
NODE_ENV=production node dist/services/PolicyEngine.js
- Instrument Endpoint: Add the middleware to a protected route.
app.post('/api/v1/data/export',
entitlementGuard('export_csv'),
exportController
);
- Test Enforcement: Simulate requests to verify access control and limit behavior.
# Test feature gate
curl -H "Authorization: Bearer <free_user_token>" http://localhost:3000/api/v1/data/export
# Expected: 403 Access Denied with upgrade prompt
# Test usage cap (loop to exceed limit)
for i in {1..1001}; do curl -H "Authorization: Bearer <free_user_token>" http://localhost:3000/api/v1/data; done
# Expected: 402 Payment Required after 1000 requests
- Monitor Metrics: Set up dashboards for
entitlement_check_latency, limit_exceeded_rate, and upgrade_conversion_rate to track system health and business impact.