Fraud Detection Architecture¶
14:02:11.900 — a $4,200 transaction is authorized. 14:02:12.300 — the fraud model finally returns a high-risk score, 400 ms after the authorization already fired. The chargeback lands six weeks later. A design review asks: what should have been on the 200 ms path that wasn't? A. A faster model. B. A 3-hop graph query to check known fraud rings before scoring. C. Keyed feature lookups only — no graph hop — with rings caught downstream instead. D. More Kafka partitions. Pick one before reading on.
C is the shape of this architecture: transactions, devices, cards, IPs, merchants, users. Four products share a bus and must not share a latency budget:
- Score this payment — p99 < 200 ms (authorisation path).
- Find rings — multi-hop graph, minutes to hours, batch is fine.
- Investigate a case — ad-hoc SQL over months.
- Report — fraud rate by MCC/region, dashboards.
No single database is good at all four. The architecture is a deliberate split. Related: graph vs relational, Spark vs Flink, Flink state.
Requirements¶
| Axis | Target |
|---|---|
| Volume | V1: 200–2,000 tx/s. Serious: 10k–50k tx/s. Each tx fans out to features, graph edges, lake rows. |
| Latency | Score: < 200 ms p99 in-process. Ring job: < 1 h. Investigation: seconds–minutes. |
| Access | Score: keyed features + rules + tiny model. Rings: 2–4 hop connectivity. Investigation: joins and time travel. |
| Retention | Features: days–weeks hot. Graph: current derived view + rebuild. Lake: years (chargebacks arrive late). |
| Cost | 200 ms path must be small. Do not hop to Neo4j and Trino on authorisation. |
| Failure | Fail open or closed is a product decision (lost sale vs fraud). Timeouts, stale features, model fallback. Replay must not alert twice without care. |
Graph on the authorisation path
A 3-hop Cypher query at p99 200 ms under load is how payments time out. Neo4j is for rings and investigators, not for POST /charge.
Capacity sketch (2,000 tx/s V1 → 20,000 at 10×)¶
| Item | 2k tx/s | Notes |
|---|---|---|
Kafka transactions | 2k/s × 1 KB × 86400 ≈ 170 GB/day | Key = transaction_id or user_id depending on ordering needs |
| Feature lookups | 2–6 CH/KV gets per tx | p99 budget: 10–20 ms each, parallel |
| Flink state | velocity windows per user/card/device | State size ≈ active keys × window. 10M active users × 1 KB is ~10 GB + RocksDB |
| Graph edges/day | few edges per tx | Batch load; not 2k Cypher writes/s in V1 |
| Lake | years of tx | Iceberg; investigators use Trino |
Partitions: 24–48 at 2k/s if scoring is heavier than the broker. Key for scoring state is user_id / card_id so velocity counts are local to a Flink key. If you key only by transaction_id, every velocity check is a distributed mess.
V1 — fewest parts (ship a score)¶
Payment API → Kafka (or sync call into a scorer that also logs to Kafka)
→ Flink (rules + keyed velocity + in-process model)
→ response (sync sidecar) or async with a tight timeout
→ Kafka `scores` + Iceberg (async)
→ ClickHouse (features and analyst tables, async)
Synchronous pattern that stays honest: the payment service calls a scorer process that shares the same Flink/feature logic or a request/reply Kafka with a 150 ms timeout. Many teams run a gRPC feature service + a model sidecar, and use Flink only to maintain features. V1 can be:
- Flink job maintains
user_id → velocityin state, writes snapshots to ClickHouse/Redis. - Scorer reads Redis/CH + local rules in < 50 ms.
Do not wait for Iceberg commits on the charge.
What V1 includes¶
- Rules: amount vs user average, country change, velocity (
>5 tx / 1 min). - Keyed Flink or Redis sliding counters.
- ClickHouse
user_featuresupdated async (seconds late is OK for some features; mark themas_of). - Iceberg append of every tx + score for later labels (chargeback).
- A fail policy: timeout → default score.
What you would not add yet¶
- Neo4j
- Online graph embeddings
- Trino on the hot path
- Pinot
- Per-feature microservices
- Exactly-once business side effects (emails) without an outbox
This architecture works while… / breaks when…¶
Works while:
the decision needs single-entity features: this user, this card, this device
the 200 ms budget is met by rules + keyed counters read from Redis/ClickHouse
a timeout can safely fall back to a default score (the fail policy is written down
and someone owns it)
feature staleness of seconds is acceptable for the async-maintained features
labels (chargebacks) arrive slowly enough that a batch retrain cadence is fine
Breaks when:
the fraud signal is relational — rings of accounts sharing devices, cards, addresses.
No amount of per-user velocity state finds a pattern that only exists BETWEEN
entities. This, not volume, is what buys a graph store.
the feature budget stops closing: each added feature is a lookup, and 200 ms is a
hard external constraint you cannot scale your way out of
fail-open becomes financially unacceptable, so the fallback path itself needs an SLO
investigation load grows — analysts need ad-hoc history, which is a different store
and a different latency class from scoring
model iteration outpaces the label pipeline, and "is this model better" cannot be
answered from what you retained
The first is the important one: it is a shape change, not a scale change. The V1 architecture cannot be tuned into answering it.
Bottleneck at the end of V1¶
| Symptom | Cause | Wrong fix |
|---|---|---|
| p99 score 800 ms | Remote CH query per feature, serial | Cache/KV; batch; denormalise into one row |
| Hot user/card | One partition / one Flink key hot | Rare; salt after scoring or isolate VIPs |
| Chargebacks not matching scores | Clock, duplicate tx ids, late labels | Idempotent tx id; lake is source for labels |
| Redis as entire feature store | Cold restart empty | Rebuild from Kafka/CH; Redis is a cache |
| Rules fire twice on replay | Side effects in the scorer | Scores are data; actions are idempotent consumers |
The 200 ms budget dies on network hops, not on a logistic regression.
V2 — add graph and investigation the slow way¶
graph TD
TX["Auth / tx events"] --> K["Kafka"]
K --> FL["Flink\nvelocity, rules, model features"]
FL --> API["Score response <200ms"]
FL --> CH["ClickHouse\nuser/device/merchant features"]
FL --> K2["graph-edge events"]
K --> ICE["Iceberg years"]
K2 --> BATCH["Spark daily/hourly"]
BATCH --> NEO["Neo4j derived graph"]
BATCH --> CH
ICE --> TRI["Trino investigation"]
TRI --> CH
NEO --> INV["Analyst ring UI"]
PG["OLTP accounts"] --> CDC["Debezium"]
CDC --> K Hot path (< 200 ms) — still no graph¶
tx in → Flink / scorer
lookup user_features (CH or KV)
lookup device_risk
velocity in local state
model in-process
→ score + reasons
ClickHouse for analytical features (30-day aggregates). Redis/KV for hot counters if CH p99 is unsafe. Total hops: one or two, in parallel.
CREATE TABLE user_features
(
user_id String,
last_updated DateTime,
avg_txn_amount_30d Float32,
txn_count_30d UInt32,
unique_devices_30d UInt16,
unique_merchants_30d UInt16,
fraud_flags_30d UInt8,
risk_score Float32
)
ENGINE = ReplacingMergeTree(last_updated)
ORDER BY user_id;
Ring detection (batch)¶
hourly/daily:
1. Edges from Kafka/Iceberg: user–device, device–ip, user–card, user–merchant
2. MERGE into Neo4j (or rebuild)
3. GDS connected components / WCC
4. Components over threshold → ClickHouse + case queue
Neo4j is derived. If it burns down, scoring continues. Rebuild from Iceberg.
Investigation¶
Trino over Iceberg + CH. Time travel when a model version is disputed. Do not give investigators production Redis.
Why four stores¶
| Question | Store | Why not ClickHouse only |
|---|---|---|
| Score now | Flink state + KV/CH row | Graph hops blow the SLA |
| 30d user average | ClickHouse | Redis is a poor 30d agg |
| Shared devices across 4 hops | Neo4j | Recursive SQL at this depth is fragile |
| "All tx for this ring last year" | Iceberg + Trino | CH TTL will have dropped raw |
How it fails¶
| Failure | Symptom | Absorb with |
|---|---|---|
| Feature store timeout | p99 spike | Default score; stale-if-error; budget per hop |
| Flink watermark stall | Velocity windows freeze | Idle watermarks; processing-time velocity if business allows |
| Skewed join in nightly labels | Spark OOM | See Spark incident |
| Graph write in Flink per tx | Neo4j falls over | Queue edges; batch |
| Poison tx payload | Scorer crash loop | DLQ; fail policy |
| Training/serving skew | Model looks great offline | Same feature code path; snapshot features with the score |
Alerting on scores
A Flink replay will re-emit scores. Downstream "block this user" consumers must be idempotent on transaction_id.
What V2 still does not add¶
- Online multi-hop during authorisation.
- A second stream processor "for ML."
- Storing PCI PAN in ClickHouse/Iceberg (tokenise; security).
- Analysts querying Kafka with Trino as the investigation UI (possible, wrong SLA).
Evolution at 10× (20k tx/s)¶
- More partitions keyed by user/card — velocity state shards with keys.
- CH cluster for features; or move the hottest keys to KV entirely.
- Regional scoring — do not RTT to another continent in the 200 ms.
- Graph job may need graph compute (Spark GraphX / GDS on a bigger box), not 10× Cypher writes.
- Feature count will try to explode; budget dimensions like cardinality in observability.
- Still no Neo4j on the hot path at 10×. Especially not at 10×.
Decision table¶
| Decision | Choice | Alternative | Why |
|---|---|---|---|
| Stream processor | Flink | Kafka Streams | Keyed state, timers, event time |
| Hot features | CH + KV | CH only | p99 |
| Graph | Neo4j derived | CH JOIN depth 1 only | Multi-hop |
| History | Iceberg | CH forever | Cost, chargeback lag |
| Investigation | Trino | Notebook vs prod Redis | Auditability |
| Model | In-process | Remote GPU | 200 ms |
Apply this at work¶
- Write the four use cases on a whiteboard. Assign an SLA and a store to each. Anything that sits on two SLAs is a bug.
- Budget the 200 ms: parse, features, model, margin. Cut hops until it fits.
- Define fail-open vs fail-closed with product/risk, not with engineering taste.
- Put
transaction_idon every score, feature snapshot, and alert. - Rebuild the graph from the lake on purpose once, so you know you can.
Pair with Flink labs (state + watermarks) and graph modelling.
Feature budget (200 ms)¶
Write a table in the design review. Numbers are illustrative; measure.
| Step | Budget (ms) | Notes |
|---|---|---|
| Parse + auth context | 5 | |
| Parallel feature fetch (CH/KV) | 40 | p99; timeout 50 |
| Velocity in Flink/local state | 5 | no network |
| Rules | 2 | |
| Model | 20 | CPU, not GPU remotely |
| Margin / GC / tail | 80 | |
| Total p99 | ≤ 200 |
If CH p99 is 80 ms, it does not fit. Move that feature to KV or accept staleness. Adding "one more microservice" is adding a tail.
Labels and chargebacks¶
Fraud models need labels that arrive days later. Architecture:
- Score time: write
transaction_id,score,model_version,feature_snapshot_idto Iceberg. - Chargeback time: join labels in Spark (minutes, skew-prone — incident 2 if one merchant).
- Never train on CH TTL data that already expired.
If you cannot join scores to labels, you do not have ML; you have rules with extra steps.
Graph edge model (batch)¶
(:User)-[:USED]->(:Device)
(:User)-[:USED]->(:IP)
(:User)-[:HAS]->(:Card)
(:User)-[:PAYS]->(:Merchant)
Connected component size > N → queue. False positives: families, offices, CGNAT IPs. IP edges need recency TTL or the graph becomes one component.
Do not emit an edge per tick of a web session. Sample or debounce in Flink before the graph topic.
Fail-open vs fail-closed¶
| Policy | When score path dies | Business |
|---|---|---|
| Fail-open | Allow payment | Lost fraud, kept conversion |
| Fail-closed | Decline | Lost conversion, safer |
| Fail-to-rules | Skip model, run velocity only | Common compromise |
This is not an engineering preference. Write it with risk. Timeouts must implement the policy, not hang until the processor dies.
PCI / PII¶
- PAN → token before Kafka.
- CVV never stored.
- Scores, devices, emails still PII-ish; mask in notebooks (security).
- Analysts query Iceberg curated, not Redis prod.
On-call 15 minutes¶
- p99 score > 200 ms: trace which hop (CH vs model vs Kafka reply). Not "scale Flink" first.
- Alert storm after a replay: missing idempotency on
transaction_id. - Graph UI empty: batch job failed; scoring should still work.
- One user hot: velocity state for a bot — isolate; do not salt before velocity (you would split the count).
Salt after you no longer need a single key for state, or use a dedicated key-group for bots.
Why Redis appears and when to remove it¶
Redis is a p99 tool for hot features. It is a bad 30-day aggregate store and a bad SoR. Populate from Flink; rebuild from CH/Kafka on flush. If CH p99 is already 8 ms, skip Redis (operational cost). V1 in many shops is CH-only until the SLO misses.