Designing a Real-Time Fraud Detection System: Rules Engines, ML Scoring, and Stream Processing at Scale
How to architect a real-time fraud detection pipeline using a three-layer approach: deterministic rule engines, ML model scoring, and stream processing for event correlation. Covers data modeling, feature stores, latency budgets, and the false positive tradeoff.
Most fraud detection systems fail in one of two ways: they block too much legitimate traffic and lose revenue, or they miss enough fraud that the chargebacks pile up. The technical problem is not training a model. It is getting a decision out in under 100ms, on every transaction, at hundreds of thousands of requests per minute, while keeping that decision accurate enough that neither outcome destroys the business.
This article covers how to architect that system. The design uses three cooperating layers: a deterministic rule engine for known fraud patterns, an ML scoring layer for statistical anomaly detection, and a stream processing layer for real-time event correlation across a user’s session. Each layer has a different latency profile, cost model, and error mode. Getting the layering right is the difference between a system that works and one that requires a team of analysts to babysit.
The Core Latency Problem
Before any architecture decisions, set the latency budget. A payment processor typically needs a fraud decision within 100-200ms end-to-end (including network). An e-commerce checkout has slightly more room, maybe 300-500ms, before cart abandonment becomes a real concern. An internal fraud review on completed transactions can tolerate seconds or minutes.
The three-layer pipeline needs to fit inside that budget:
Transaction event received
└── Layer 1: Rule Engine ~5-15ms
└── Layer 2: ML Feature Fetch ~10-30ms
└── Layer 2: ML Model Inference ~20-50ms
└── Layer 3: Stream Correlation ~15-40ms (async enrichment or parallel)
└── Decision aggregation ~2-5ms
Total: ~50-140ms p95
Stream correlation (Layer 3) often runs in parallel with the ML layer or as an async enrichment pass rather than serially. The model scoring path is the critical path.
Data Modeling for Transaction Events
Every downstream decision is only as good as the event schema. Design this schema to be immutable and append-only from day one. Fraud investigations happen weeks after the fact and you cannot reconstruct a case if you mutated records.
interface TransactionEvent {
eventId: string; // UUID, idempotency key
occurredAt: string; // ISO 8601, client-reported time
receivedAt: string; // server ingestion time
userId: string;
sessionId: string;
deviceFingerprint: string;
ipAddress: string;
amount: number; // integer cents
currency: string; // ISO 4217
merchantId: string;
merchantCategory: string; // MCC code
paymentMethod: {
type: "card" | "bank_transfer" | "wallet";
tokenId: string; // never store raw card numbers
last4?: string;
binCountry?: string; // card issuer country
};
billingAddress: {
countryCode: string;
postalCode: string;
};
metadata: Record<string, string>; // extensible, string values only
}
The occurredAt vs receivedAt split matters for the same reason it matters in billing: clock skew is real on mobile clients, and you need to know both the claimed time and the observed time to detect replay attacks and impossible travel patterns.
Layer 1: The Rule Engine
The rule engine handles the deterministic cases: known bad card numbers, blocked countries, velocity limits, exact-match deny lists. Rules are cheap to evaluate (microseconds), easy to audit, and simple to explain to a compliance team.
The key design decision is whether rules are code or data. Code rules are fast and type-safe but require a deploy to update. Data-driven rules can be updated without a deploy but add a configuration layer that needs its own access controls and audit log.
For most production systems, a hybrid works best: the evaluation engine is code, but individual rules are loaded from a configuration store at startup (and re-loaded on a short TTL).
interface FraudRule {
ruleId: string;
name: string;
enabled: boolean;
action: "block" | "flag" | "allow";
conditions: RuleCondition[];
logicOperator: "AND" | "OR";
priority: number; // lower = evaluated first
}
interface RuleCondition {
field: string; // dot-path into TransactionEvent
operator: "eq" | "neq" | "gt" | "lt" | "in" | "not_in" | "regex";
value: unknown;
}
function evaluateRule(
rule: FraudRule,
event: TransactionEvent,
): { matched: boolean; action: FraudRule["action"] } {
const results = rule.conditions.map((condition) =>
evaluateCondition(condition, event),
);
const matched =
rule.logicOperator === "AND"
? results.every(Boolean)
: results.some(Boolean);
return { matched, action: rule.action };
}
function evaluateCondition(
condition: RuleCondition,
event: TransactionEvent,
): boolean {
const fieldValue = getNestedValue(event, condition.field);
switch (condition.operator) {
case "eq":
return fieldValue === condition.value;
case "neq":
return fieldValue !== condition.value;
case "gt":
return typeof fieldValue === "number" && fieldValue > (condition.value as number);
case "lt":
return typeof fieldValue === "number" && fieldValue < (condition.value as number);
case "in":
return Array.isArray(condition.value) && condition.value.includes(fieldValue);
case "not_in":
return Array.isArray(condition.value) && !condition.value.includes(fieldValue);
case "regex":
return typeof fieldValue === "string" &&
new RegExp(condition.value as string).test(fieldValue);
default:
return false;
}
}
Run rules in priority order and short-circuit on the first block match. A flag result does not stop processing. Collect all flag results and pass them downstream as signals to the ML layer.
One common mistake: building velocity checks into the rule engine itself. “More than 3 transactions in 10 minutes” requires stateful lookups (Redis or a sliding window counter). That is fine, but it should be isolated as a separate “velocity check” step that feeds into rule evaluation, not mixed into the condition evaluator above.
Layer 2: ML Scoring with Feature Stores
Rule engines catch known patterns. ML models catch unknown ones: a card being tested with small transactions before a large one, a user whose behavior suddenly diverges from their own history, a device fingerprint that appears across multiple accounts.
The model itself (gradient boosting, neural net, does not matter here) is not the hard part. The hard part is getting the features to the model fast enough.
Feature Store Design
Features for fraud scoring fall into two categories:
Real-time features computed from the current event and recent history: velocity counts, time-since-last-transaction, deviation from typical transaction amount.
Pre-computed features derived from longer historical windows: 30-day average spend, typical merchant categories, device trust score.
interface UserFeatures {
userId: string;
computedAt: string;
// Pre-computed, updated hourly via batch job
avg30DayAmount: number; // integer cents
stddevAmount: number;
topMerchantCategories: string[];
countryHistory: string[];
trustedDeviceCount: number;
// Real-time, computed on request
txCountLastHour: number;
txCountLast10Min: number;
amountLastHour: number;
uniqueMerchantsLast24h: number;
timeSinceLastTxSeconds: number;
}
async function buildFeatureVector(
event: TransactionEvent,
redis: RedisClient,
featureStore: FeatureStoreClient,
): Promise<UserFeatures> {
const [precomputed, velocityCounts] = await Promise.all([
featureStore.get<UserFeatures>(event.userId),
fetchVelocityCounters(event.userId, redis),
]);
return {
...precomputed,
...velocityCounts,
userId: event.userId,
computedAt: new Date().toISOString(),
};
}
async function fetchVelocityCounters(
userId: string,
redis: RedisClient,
): Promise<Pick<UserFeatures, "txCountLastHour" | "txCountLast10Min" | "amountLastHour">> {
const now = Date.now();
const hourAgo = now - 3600 * 1000;
const tenMinAgo = now - 600 * 1000;
// Sorted set: score = timestamp, member = eventId
const [lastHourEvents, last10MinEvents] = await Promise.all([
redis.zrangebyscore(`velocity:tx:${userId}`, hourAgo, now),
redis.zrangebyscore(`velocity:tx:${userId}`, tenMinAgo, now),
]);
return {
txCountLastHour: lastHourEvents.length,
txCountLast10Min: last10MinEvents.length,
amountLastHour: 0, // fetch from a separate sorted set tracking amounts
};
}
The latency target for feature assembly is under 30ms at p95. That requires pre-computing anything that requires a full table scan, and ensuring Redis keys are on the same cluster node (use hash tags in key names to control sharding).
Model Inference
Keep the model serving layer stateless. Load the model artifact at startup, and score synchronously per request. For most fraud models, inference latency is 5-20ms. If you are running deep learning models that take longer, consider caching scores for repeat feature vectors (rare) or switching to a lighter model for the latency-sensitive path.
The model outputs a score between 0 and 1. The decision thresholds are not a machine learning problem. They are a business policy decision.
Layer 3: Stream Processing for Event Correlation
Some fraud patterns are invisible on a single transaction but obvious across a sequence. A card being tested (small transactions to different merchants in a short window). Account takeover that starts with a password reset, proceeds to email change, then makes a large transaction. Geographic impossibility (transaction in New York followed 3 minutes later by one in London).
This requires correlating events across time within a session or across a user’s recent history.
interface FraudSignal {
signalType:
| "card_testing"
| "impossible_travel"
| "account_takeover_sequence"
| "velocity_burst";
confidence: number; // 0 to 1
evidenceEventIds: string[];
detectedAt: string;
}
// Kafka Streams / Flink-style windowed aggregation (pseudocode)
interface SessionWindow {
userId: string;
windowStart: string;
windowEnd: string;
events: TransactionEvent[];
signals: FraudSignal[];
}
function detectCardTesting(events: TransactionEvent[]): FraudSignal | null {
// Card testing pattern: 3+ small transactions to different merchants
// within 10 minutes, all under $10
const recentSmall = events.filter(
(e) => e.amount < 1000 && // under $10 in cents
Date.now() - new Date(e.occurredAt).getTime() < 600_000,
);
const uniqueMerchants = new Set(recentSmall.map((e) => e.merchantId));
if (recentSmall.length >= 3 && uniqueMerchants.size >= 3) {
return {
signalType: "card_testing",
confidence: Math.min(0.5 + (recentSmall.length - 3) * 0.1, 0.95),
evidenceEventIds: recentSmall.map((e) => e.eventId),
detectedAt: new Date().toISOString(),
};
}
return null;
}
function detectImpossibleTravel(
current: TransactionEvent,
previous: TransactionEvent | null,
): FraudSignal | null {
if (!previous) return null;
const deltaMs =
new Date(current.occurredAt).getTime() -
new Date(previous.occurredAt).getTime();
const deltaSeconds = deltaMs / 1000;
// Different countries within 30 minutes
const currentCountry = current.billingAddress.countryCode;
const previousCountry = previous.billingAddress.countryCode;
if (currentCountry !== previousCountry && deltaSeconds < 1800) {
return {
signalType: "impossible_travel",
confidence: 0.85,
evidenceEventIds: [previous.eventId, current.eventId],
detectedAt: new Date().toISOString(),
};
}
return null;
}
For stream processing at scale, Kafka + Flink is the production standard. The session window state lives in Flink’s managed state (RocksDB backend), which handles the durability and replication. For lower volume systems, a Redis sorted set per user with a 24-hour TTL is often sufficient.
The stream layer typically runs slightly behind real-time (100-500ms processing lag). For the synchronous fraud decision path, you have two options:
- Run stream correlation in parallel with ML scoring and merge results before returning the decision.
- Use pre-materialized signal outputs from the stream layer: the Flink job writes active signals to Redis, and the decision layer reads those signals synchronously.
Option 2 introduces a small staleness window (up to the stream processing lag) but keeps the synchronous path simpler.
Decision Aggregation
All three layers produce signals. The aggregation step combines them into a final action.
interface FraudDecision {
eventId: string;
action: "allow" | "flag" | "block" | "require_3ds";
score: number; // 0 to 1, combined
reasons: string[]; // human-readable, for review queues
decidedAt: string;
layerOutputs: {
ruleEngine: { action: FraudRule["action"]; matchedRules: string[] };
mlScore: number;
streamSignals: FraudSignal[];
};
}
function aggregateDecision(
event: TransactionEvent,
ruleResult: { action: FraudRule["action"]; matchedRules: string[] },
mlScore: number,
streamSignals: FraudSignal[],
thresholds: { block: number; require3ds: number; flag: number },
): FraudDecision {
// Rule engine hard blocks override everything
if (ruleResult.action === "block") {
return {
eventId: event.eventId,
action: "block",
score: 1.0,
reasons: ruleResult.matchedRules,
decidedAt: new Date().toISOString(),
layerOutputs: { ruleEngine: ruleResult, mlScore, streamSignals },
};
}
// Combine ML score with stream signal confidence
const maxSignalConfidence = streamSignals.reduce(
(max, s) => Math.max(max, s.confidence),
0,
);
const combinedScore = Math.min(
mlScore * 0.7 + maxSignalConfidence * 0.3,
1.0,
);
let action: FraudDecision["action"];
if (combinedScore >= thresholds.block) {
action = "block";
} else if (combinedScore >= thresholds.require3ds) {
action = "require_3ds";
} else if (combinedScore >= thresholds.flag || ruleResult.action === "flag") {
action = "flag";
} else {
action = "allow";
}
const reasons: string[] = [
...ruleResult.matchedRules,
...streamSignals.map((s) => s.signalType),
];
return {
eventId: event.eventId,
action,
score: combinedScore,
reasons,
decidedAt: new Date().toISOString(),
layerOutputs: { ruleEngine: ruleResult, mlScore, streamSignals },
};
}
The False Positive / False Negative Tradeoff
This is the central business decision in any fraud system. There is no technically correct threshold. There is only the threshold that fits the business’s risk tolerance.
| Dimension | Low Threshold (Aggressive) | High Threshold (Permissive) | Notes |
|---|---|---|---|
| False positive rate | High | Low | Blocks legitimate users |
| False negative rate | Low | High | Lets fraud through |
| Chargeback exposure | Low | High | Cost to the business |
| Cart abandonment | High | Low | Revenue lost to friction |
| Customer experience | Degraded | Normal | Support ticket volume |
| Review queue volume | High | Low | Analyst headcount |
The operationally honest answer is to instrument both. Track chargeback rate and blocked-legitimate-user rate as separate business metrics, set targets for each, and tune thresholds to meet both targets simultaneously. If you cannot meet both, that is a business decision about which you are willing to tolerate more of.
One important detail: never apply the same threshold universally. A $15 subscription renewal has a different risk profile than a $3,000 electronics purchase from a new device. Segment thresholds by transaction type, merchant category, and user tenure.
Production Considerations
Model drift. Fraud patterns evolve. A model trained on data from 6 months ago will degrade. Build a continuous retraining pipeline: label chargebacks as ground truth fraud, retrain weekly or monthly, A/B test new model versions with a small traffic slice before full rollout.
Feature skew. The feature values at training time need to match feature values at inference time exactly. If your training pipeline computes avg30DayAmount differently than your online feature store, the model will score incorrectly in production. Validate feature distributions between offline and online at deployment.
Rule lifecycle. Add an audit log for every rule change. Include who changed it, why, and what the expected impact was. Fraud rules often get added reactively during incidents and then forgotten. A rule that blocks a country code because of one incident 18 months ago is costing you legitimate customers from that country today.
Observability. The five metrics that matter: fraud rate (chargebacks / total transactions), false positive rate (blocked legitimate / total blocked), model score distribution (alert on distribution shift), rule match rate per rule (find stale or broken rules), and decision latency p50/p95/p99.
Explainability for disputes. When a legitimate customer calls to dispute a block, your support team needs to understand why. Store the FraudDecision including layerOutputs and reasons for every transaction. A “your transaction was flagged due to card_testing signal from 3 small transactions in 10 minutes” is explainable. A raw score of 0.73 is not.
Idempotency. The fraud decision endpoint will be called more than once on the same event during network retries or payment processor retries. Use eventId as an idempotency key. Store the decision result and return it on duplicate requests without re-evaluating.
-- Decision store
CREATE TABLE fraud_decisions (
event_id UUID PRIMARY KEY,
action TEXT NOT NULL,
score NUMERIC(4, 3) NOT NULL,
reasons JSONB NOT NULL,
layer_outputs JSONB NOT NULL,
decided_at TIMESTAMPTZ NOT NULL
);
-- ON CONFLICT ensures idempotent writes
INSERT INTO fraud_decisions (event_id, action, score, reasons, layer_outputs, decided_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (event_id) DO NOTHING;
Tradeoffs at Scale
| Dimension | Rule Engine | ML Scoring | Stream Correlation |
|---|---|---|---|
| Latency | 5-15ms | 20-50ms | 15-40ms (parallel) |
| Coverage | Known patterns only | Statistical anomalies | Cross-event patterns |
| Explainability | High | Low to medium | Medium |
| Update frequency | Minutes (config reload) | Weekly (retrain) | Real-time (stateful) |
| Operational cost | Low | Medium | High |
| Error mode | Stale rules miss new patterns | Model drift, feature skew | Window state management |
Closing Thought
The architecture described here is not novel. The challenge is executing each layer without letting it bloat: rules that never get pruned, ML pipelines that break when the feature store changes, stream jobs that accumulate state indefinitely. The real maintenance surface is not the detection logic. It is the data contracts between layers and the operational discipline to keep them honest over time. Build the audit log, the observability, and the idempotency first. The detection accuracy follows from having clean data and clean decisions to learn from.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.