ct must be versioned and immutable.
// types.ts
export interface UserAttributes {
id: string;
tier: 'free' | 'pro' | 'enterprise';
region: string;
signupDate: string;
customFields: Record<string, string | number | boolean>;
}
export interface EvaluationContext {
user: UserAttributes;
session: {
deviceId: string;
platform: 'web' | 'ios' | 'android';
referrer?: string;
};
timestamp: number;
}
export type RuleOperator = 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'in' | 'contains' | 'regex';
export interface RuleCondition {
path: string; // JSONPath or dot notation
operator: RuleOperator;
value: any;
}
export interface SegmentationRule {
id: string;
name: string;
version: number;
conditions: RuleCondition[];
logic: 'AND' | 'OR';
metadata?: Record<string, string>;
}
Step 2: Build the Rule Evaluation Engine
The engine resolves dot-notation paths, applies type-safe comparisons, and short-circuits based on logical operators.
// ruleEngine.ts
import { EvaluationContext, SegmentationRule, RuleCondition } from './types';
export class SegmentationEngine {
private cache = new Map<string, { result: boolean; expires: number }>();
private readonly CACHE_TTL_MS = 30000; // 30s
evaluate(rule: SegmentationRule, context: EvaluationContext): boolean {
const cacheKey = `${rule.id}:${rule.version}:${context.user.id}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() < cached.expires) {
return cached.result;
}
const results = rule.conditions.map(c => this.evaluateCondition(c, context));
const finalResult = rule.logic === 'AND'
? results.every(Boolean)
: results.some(Boolean);
this.cache.set(cacheKey, { result: finalResult, expires: Date.now() + this.CACHE_TTL_MS });
return finalResult;
}
private evaluateCondition(condition: RuleCondition, context: EvaluationContext): boolean {
const value = this.resolvePath(context, condition.path);
if (value === undefined) return false;
switch (condition.operator) {
case 'eq': return value === condition.value;
case 'neq': return value !== condition.value;
case 'gt': return Number(value) > Number(condition.value);
case 'lt': return Number(value) < Number(condition.value);
case 'gte': return Number(value) >= Number(condition.value);
case 'lte': return Number(value) <= Number(condition.value);
case 'in': return Array.isArray(condition.value) && condition.value.includes(value);
case 'contains': return String(value).includes(String(condition.value));
case 'regex': return new RegExp(condition.value).test(String(value));
default: return false;
}
}
private resolvePath(obj: any, path: string): any {
return path.split('.').reduce((current, key) =>
current && current[key] !== undefined ? current[key] : undefined, obj);
}
invalidate(userIds: string[], ruleId?: string): void {
for (const [key] of this.cache) {
if (ruleId && !key.startsWith(ruleId)) continue;
if (userIds.some(uid => key.endsWith(`:${uid}`))) {
this.cache.delete(key);
}
}
}
}
Step 3: Attribute Resolution Pipeline
Attributes must be resolved from multiple sources (Postgres, Redis, event streams) with deterministic fallbacks. Implement a resolver that prioritizes cache, then falls back to database queries, and finally to defaults.
// attributeResolver.ts
import { UserAttributes } from './types';
export class AttributeResolver {
constructor(
private redis: any,
private db: any,
private fallback: Partial<UserAttributes> = {}
) {}
async resolve(userId: string): Promise<UserAttributes> {
const cached = await this.redis.hgetall(`user:attrs:${userId}`);
if (Object.keys(cached).length > 0) {
return { ...this.fallback, ...cached, id: userId } as UserAttributes;
}
const row = await this.db.query('SELECT * FROM users WHERE id = $1', [userId]);
if (!row?.length) return { ...this.fallback, id: userId } as UserAttributes;
await this.redis.hset(`user:attrs:${userId}`, {
tier: row[0].tier,
region: row[0].region,
signupDate: row[0].signup_date,
customFields: JSON.stringify(row[0].custom_fields || {})
});
await this.redis.expire(`user:attrs:${userId}`, 300);
return { ...this.fallback, ...row[0], id: userId } as UserAttributes;
}
}
Step 4: Architecture Decisions & Rationale
- JSON-based rules over DSL: Enables non-engineer configuration, version control via Git, and safe rollout through feature flag systems. Custom DSLs introduce parsing overhead and lock teams into proprietary syntax.
- In-memory cache with TTL: Redis or local Node cache reduces database load by 85%+ for repeated evaluations. TTL prevents stale state without requiring complex invalidation broadcasts.
- Deterministic evaluation: Rules must produce identical results for identical contexts. Avoid non-deterministic functions (e.g.,
Math.random()) inside evaluation logic; use seeded hashing for bucketing.
- Event-driven invalidation: When user attributes change, emit a
user.updated event. Subscribers invalidate cache entries and trigger rule re-evaluation. This decouples state mutation from evaluation.
Pitfall Guide
-
Embedding rules in application code
Hardcoding if (user.plan === 'pro') ties segmentation to deployment cycles. Every rule change requires a PR, code review, and release. This collapses experiment velocity and creates merge conflicts across teams. Fix: Externalize rules to a versioned configuration store evaluated at runtime.
-
Ignoring attribute staleness
Caching user attributes indefinitely causes targeting drift. A user upgraded yesterday but still receives free-tier UI because the cache TTL was set to 24 hours. Fix: Implement short TTLs (30-60s) with event-driven invalidation. Track last_updated timestamps and compare against rule evaluation time.
-
Overcomplicating rule syntax
Supporting nested JSONPath, custom functions, and dynamic variable injection increases evaluation latency and debugging complexity. Most production systems only need flat attribute comparisons with logical operators. Fix: Restrict to dot-notation paths and standard operators. Document supported types explicitly.
-
Missing fallback chains
When the attribute resolver fails or times out, evaluation should not throw. Silent failures corrupt experiment data and break feature gates. Fix: Implement a fallback strategy: cache β DB β defaults β deny. Log degradation events and trigger circuit breakers after N consecutive failures.
-
Single-source-of-truth fallacy
Assuming Postgres is the source of truth for real-time evaluation creates bottlenecks. High-concurrency systems require read-optimized stores (Redis, DynamoDB, or ClickHouse) synced via CDC or event streams. Fix: Separate write path (transactional DB) from read path (attribute store). Use outbox pattern or Debezium for reliable sync.
-
No observability on evaluation paths
Without metrics, teams cannot detect cache stampedes, rule version mismatches, or latency regressions. Fix: Instrument evaluation calls with segmentation.evaluate.duration, segmentation.cache.hit_ratio, and segmentation.rule.version. Alert on p95 > 50ms or hit ratio < 70%.
-
Race conditions in anonymous-to-authenticated stitching
Users browsing anonymously trigger segmentation rules. Upon signup, their attributes change, but in-flight requests still use anonymous context. This causes inconsistent UI states during onboarding. Fix: Maintain separate anonymous and authenticated attribute stores. Emit a user.stitched event to invalidate caches and re-evaluate active sessions.
Production best practices:
- Version rules and roll out via canary deployment (e.g., 10% traffic β 50% β 100%).
- Keep rule evaluation pure: no side effects, no network calls during evaluation.
- Use deterministic hashing for percentage-based rollouts to ensure consistent bucketing across services.
- Audit all rule changes with author, timestamp, and diff. Integrate with Slack/Teams for change notifications.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP (<10k MAU) | In-memory rule engine + Postgres fallback | Low operational overhead, fast iteration, minimal infra | $0-50/mo (single node) |
| High-scale SaaS (>1M MAU) | Centralized engine + Redis cache + Kafka invalidation | Handles 45k+ evals/sec, prevents DB saturation, enables cross-service consistency | $200-800/mo (Redis cluster + Kafka) |
| Compliance-heavy (GDPR/CCPA) | Immutable rule versions + explicit consent gating | Auditability, data minimization, reversible targeting decisions | +15% dev overhead, lower legal risk |
| ML-driven personalization | Hybrid: deterministic rules for gating + ML for ranking | ML adds latency; keep gating fast, use ML for content ordering | +$1k/mo (GPU/ML infra), higher experimentation ROI |
Configuration Template
{
"ruleId": "pro_dashboard_v2",
"version": 3,
"name": "Pro Dashboard Access",
"logic": "AND",
"conditions": [
{ "path": "user.tier", "operator": "in", "value": ["pro", "enterprise"] },
{ "path": "user.region", "operator": "eq", "value": "us-east-1" },
{ "path": "session.platform", "operator": "in", "value": ["web", "ios"] }
],
"metadata": {
"owner": "product-growth",
"created": "2024-08-12T09:00:00Z",
"experimentId": "exp-dashboard-redesign-42"
}
}
Quick Start Guide
- Initialize the engine: Install dependencies (
npm i redis ioredis), create SegmentationEngine and AttributeResolver instances, and load rules from a JSON file or config service.
- Define your first rule: Use the configuration template to create a rule targeting
user.tier === 'pro' and session.platform === 'web'. Save as rules.json.
- Run evaluation: Call
engine.evaluate(rule, context) in your request handler. Pass a mock EvaluationContext to verify logic. Check cache hit ratio via engine.cache.size.
- Wire invalidation: Subscribe to your user update event stream. Call
engine.invalidate([userId]) when tier or region changes. Verify with a second evaluation call.
- Deploy: Containerize the evaluator as a sidecar or shared library. Route 10% of traffic through the new engine, monitor p95 latency and cache ratio, then promote to 100%.