description: string;
limits: FeatureLimit[];
dependencies?: string[]; // IDs of required features
requiresApproval?: boolean;
}
export interface TierDefinition {
id: string;
name: string;
features: Record<string, FeatureLimit>; // Feature ID -> Limit override
metadata?: Record<string, string>;
}
// Example Configuration
export const PRODUCT_LINES: TierDefinition[] = [
{
id: 'tier-pro',
name: 'Pro',
features: {
'api_access': { type: 'boolean', max: 1 },
'team_members': { type: 'count', max: 10 },
'storage': { type: 'storage', max: 5000, unit: 'MB' }
}
},
{
id: 'tier-enterprise',
name: 'Enterprise',
features: {
'api_access': { type: 'boolean', max: 1 },
'team_members': { type: 'count', max: -1 }, // Unlimited
'storage': { type: 'storage', max: -1, unit: 'MB' },
'sso': { type: 'boolean', max: 1 },
'audit_logs': { type: 'boolean', max: 1 }
}
}
];
### 2. Centralized Entitlement Service
The Entitlement Service acts as the single source of truth. It resolves the effective permissions for a tenant by combining the base tier definition, any add-ons, and custom overrides. This service must be low-latency and highly available.
**Architecture Decision:** Implement an in-memory cache (e.g., Redis) for active tenant entitlements. Refresh the cache asynchronously upon billing events or admin changes. Use Attribute-Based Access Control (ABAC) for fine-grained enforcement.
**TypeScript Implementation: Entitlement Engine**
```typescript
// services/entitlement-service.ts
import { TierDefinition, FeatureLimit } from '../models/feature-model';
import { RedisClient } from './redis-client';
export class EntitlementService {
private cache: RedisClient;
private tiers: Map<string, TierDefinition>;
constructor(tiers: TierDefinition[], redis: RedisClient) {
this.tiers = new Map(tiers.map(t => [t.id, t]));
this.cache = redis;
}
async getEffectiveLimits(tenantId: string): Promise<Map<string, FeatureLimit>> {
const cacheKey = `entitlements:${tenantId}`;
const cached = await this.cache.get(cacheKey);
if (cached) {
return new Map(Object.entries(JSON.parse(cached)));
}
// Resolve logic: Tier + Add-ons + Overrides
const tenantData = await this.fetchTenantContext(tenantId);
const baseTier = this.tiers.get(tenantData.tierId)!;
// Merge logic (simplified)
const effective = new Map<string, FeatureLimit>();
Object.entries(baseTier.features).forEach(([featureId, limit]) => {
effective.set(featureId, { ...limit });
});
// Apply custom overrides
if (tenantData.overrides) {
tenantData.overrides.forEach(({ featureId, limit }) => {
effective.set(featureId, limit);
});
}
await this.cache.set(cacheKey, JSON.stringify(Object.fromEntries(effective)), 'EX', 3600);
return effective;
}
async checkAccess(tenantId: string, featureId: string): Promise<boolean> {
const limits = await this.getEffectiveLimits(tenantId);
const limit = limits.get(featureId);
if (!limit) return false;
if (limit.type === 'boolean') return limit.max === 1;
return true; // Usage checks handled separately
}
async checkUsageLimit(tenantId: string, featureId: string, currentUsage: number): Promise<boolean> {
const limits = await this.getEffectiveLimits(tenantId);
const limit = limits.get(featureId);
if (!limit || limit.type === 'boolean') return false;
if (limit.max === -1) return true; // Unlimited
return currentUsage < limit.max;
}
private async fetchTenantContext(tenantId: string) {
// Fetch from DB: tier, add-ons, overrides
// Implementation depends on schema design
return {
tierId: 'tier-pro',
overrides: []
};
}
}
3. Database Schema and Multi-Tenancy
The database strategy must support efficient querying of usage metrics for limit enforcement while maintaining data isolation.
Architecture Decision:
- Single Database, Shared Schema: Use a
tenant_id column on all tables. This is the most cost-effective and easiest to scale horizontally.
- Usage Tracking: Implement a dedicated
usage_events table or time-series database for high-volume metrics (API calls, storage). Aggregations should be pre-calculated to avoid expensive COUNT queries during entitlement checks.
- JSONB for Flexibility: Use JSONB columns for tier-specific configuration that varies by tenant, allowing schema evolution without migrations.
Schema Design Snippet (PostgreSQL):
CREATE TABLE tenants (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
tier_id VARCHAR(50) NOT NULL,
custom_config JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE usage_metrics (
tenant_id UUID REFERENCES tenants(id),
feature_id VARCHAR(50),
period_start DATE,
current_value BIGINT DEFAULT 0,
UNIQUE(tenant_id, feature_id, period_start)
);
-- Index for fast entitlement lookups
CREATE INDEX idx_tenants_tier ON tenants(tier_id);
CREATE INDEX idx_usage_tenant_feature ON usage_metrics(tenant_id, feature_id);
4. Enforcement Middleware
Entitlement checks must be enforced at the API gateway or application middleware layer, not buried in business logic. This ensures consistent enforcement across all endpoints.
TypeScript Implementation: Middleware
// middleware/entitlement-middleware.ts
import { Request, Response, NextFunction } from 'express';
import { EntitlementService } from '../services/entitlement-service';
export function requireFeature(featureId: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) return res.status(401).json({ error: 'Missing tenant context' });
const entitlementService = req.app.get('entitlementService') as EntitlementService;
const hasAccess = await entitlementService.checkAccess(tenantId, featureId);
if (!hasAccess) {
return res.status(403).json({
error: 'Feature not available',
code: 'ENTITLEMENT_DENIED',
feature: featureId
});
}
next();
};
}
// Usage in route
app.post('/api/v1/reports',
requireFeature('advanced_analytics'),
async (req, res) => {
// Business logic only executes if entitlement passes
}
);
Pitfall Guide
1. Hardcoding Tier Logic in Business Code
Mistake: Writing if (user.plan === 'enterprise') { ... } scattered across controllers and services.
Consequence: When a feature is added to a new tier or removed, developers must hunt through the codebase. This leads to missed updates and inconsistent behavior.
Best Practice: All tier checks must route through the Entitlement Service. Business logic should only request capabilities, not check tiers.
2. Ignoring Usage-Based Limits vs. Flat Features
Mistake: Treating all features as binary booleans. SaaS products often sell capacity (e.g., "500 API calls/hour").
Consequence: Customers hit hard limits without warning, or the system allows unlimited usage for paid tiers due to missing usage tracking.
Best Practice: Implement a dual-check system: checkAccess for feature availability and checkUsageLimit for capacity. Integrate usage counters that atomically increment and check limits.
3. The "Enterprise" Customization Trap
Mistake: Creating if (tenant.isEnterprise) branches that introduce significant code divergence.
Consequence: The codebase fragments. Upgrading the core product becomes risky for enterprise tenants. Technical debt compounds rapidly.
Best Practice: Use extension points, plugins, or webhooks for enterprise-specific behavior. Keep the core monolithic and uniform. If a feature is enterprise-only, gate it via entitlements, not code forks.
4. Billing and Entitlement Drift
Mistake: Relying on the billing provider (e.g., Stripe) as the source of truth for feature access without a reconciliation process.
Consequence: Webhook failures or manual adjustments in billing create mismatches. Customers pay for features they can't use, or access features they haven't paid for.
Best Practice: Implement a daily reconciliation job that compares billing records against entitlement records. Alert on discrepancies. The Entitlement Service should be the runtime source of truth, synchronized with billing.
5. Flag Sprawl and Lack of Hygiene
Mistake: Creating feature flags for every tier variation without a cleanup policy.
Consequence: The flag configuration becomes unmanageable. Dead flags accumulate, increasing cognitive load and risk of misconfiguration.
Best Practice: Enforce flag lifecycle management. Flags must have an expiration date or a migration plan. Use the Entitlement Service to manage static tier features, reserving feature flags for experiments and gradual rollouts.
6. Neglecting the Downgrade Path
Mistake: Designing for upgrades but failing to handle downgrades gracefully.
Consequence: When a customer downgrades, data may be lost, or features may remain enabled until the next billing cycle, causing support tickets.
Best Practice: Define downgrade policies explicitly. Implement "grace periods" where data is preserved but write access is restricted. Ensure the entitlement engine can handle immediate revocation of features upon downgrade events.
7. Over-Engineering the Abstraction Early
Mistake: Building a complex distributed entitlement service before the product has multiple tiers or significant scale.
Consequence: Unnecessary complexity and latency for a simple product.
Best Practice: Start with a simple configuration file or database table. Refactor to a centralized service once you have three or more tiers, or when deployment friction exceeds acceptable thresholds.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| < 3 Tiers, Simple Features | Database Config + Middleware | Low overhead; sufficient for early stage. | Low engineering cost. |
| Usage-Based Pricing | Dedicated Usage Service + Entitlement | Requires high-throughput counting and real-time limits. | Medium infrastructure cost; high value for accuracy. |
| Enterprise Customizations | Plugin Architecture / Webhooks | Prevents code divergence; maintains core stability. | Medium dev cost; reduces long-term maintenance. |
| Global Scale / High Latency Sensitivity | Distributed Cache + Edge Enforcement | Reduces latency for entitlement checks at the edge. | Higher infra cost; improves UX significantly. |
| Complex Add-ons & Bundles | Rule Engine (e.g., JSONLogic) | Handles complex dependency logic declaratively. | Medium config complexity; flexible sales models. |
Configuration Template
Use this template to define your product line structure. This can be stored in a database or version-controlled configuration file.
{
"productLine": {
"version": "1.2.0",
"tiers": [
{
"id": "starter",
"name": "Starter",
"features": {
"basic_reports": { "type": "boolean", "max": 1 },
"users": { "type": "count", "max": 5 },
"api_calls": { "type": "count", "max": 1000, "unit": "per_hour" }
}
},
{
"id": "growth",
"name": "Growth",
"features": {
"basic_reports": { "type": "boolean", "max": 1 },
"advanced_analytics": { "type": "boolean", "max": 1 },
"users": { "type": "count", "max": 50 },
"api_calls": { "type": "count", "max": 50000, "unit": "per_hour" },
"priority_support": { "type": "boolean", "max": 1 }
}
}
],
"addOns": [
{
"id": "extra_storage",
"name": "Extra Storage",
"baseLimit": 0,
"increment": 10000,
"unit": "MB",
"featureId": "storage"
}
]
}
}
Quick Start Guide
- Initialize Entitlement Config: Create a
features.json file based on the Configuration Template. Load this into your application startup.
- Add Tenant Context: Ensure every API request includes a
tenant_id or account_id header.
- Implement Simple Check: Add a function
hasFeature(tenantId, featureName) that reads the tenant's tier from your DB and checks against features.json.
- Wrap Routes: Apply
hasFeature checks to routes corresponding to gated features.
- Verify: Test with a tenant on a lower tier accessing a gated route. Confirm
403 Forbidden is returned. Test with a higher tier. Confirm access is granted.
By implementing this architecture, you transform your product line strategy from a source of engineering friction into a scalable asset. The system becomes responsive to market changes, reduces operational risk, and frees engineering teams to focus on core value creation rather than tier management.