Designing a Real-Time Transaction Fraud Detection System
1. Overview
Every time a customer swipes a card, the payment system has dozens of milliseconds to answer: is this transaction trustworthy? A slow answer ruins the payment experience. A wrong answer costs you money, or you wrongly block a real customer.
This article walks through designing a real-time fraud detection system. The scope includes requirements, data model, API, and processing flow. The article also tackles three hard problems: contention on hot keys, consistency during retries, and burst load. After reading, you have an architecture framework using Spring Boot 3, Redis, PostgreSQL, and Kafka that you can implement yourself.
2. Requirements
2.1. Functional Requirements
- Receive a transaction and return a decision
APPROVE,REVIEW, orDECLINE, along with risk score and reasons. - Apply rules: unusual amount, high transaction frequency (velocity), risky country.
- Keep every decision for audit and future model training.
- Push
REVIEWdecisions to the investigation team (case management).
2.2. Scale and Constraints
| Metric | Value |
|---|---|
| Average throughput | 3,000 TPS |
| Peak throughput (Black Friday, 11/11) | 30,000 TPS |
| API latency | p99 < 50ms |
| Decision storage | ~260M records/day at peak, retain 2 years |
Three important constraints:
- Idempotent: the payment gateway may retry the same transaction. Each retry must receive the same decision.
- No lost events: once a decision is returned, it must be recorded and published.
- Explainable: every
DECLINEmust list specific reasons.
3. Architecture and Processing Flow
The flow splits into two parts. The synchronous part sits on the payment path and must be fast. The asynchronous part does everything else.
Payment Gateway
│ POST /api/v1/fraud/score
▼
┌──────────────────────┐ velocity, profile ┌─────────┐
│ Fraud Scoring API │ ────────────────────▶ │ Redis │
│ (stateless, N pod) │ └─────────┘
└──────────┬───────────┘
│ 1 transaction: decision + outbox
▼
┌────────────┐ outbox relay ┌──────────────────────┐
│ PostgreSQL │ ───────────────▶ │ Kafka: fraud.decisions│
└────────────┘ └──────────┬───────────┘
▼
Case management, analytics, ML feature store
Specific steps:
- API receives request and checks if there’s already a decision for
transactionId. - Run the rules. Rules needing state (velocity) read and write Redis using a single atomic command.
- Sum scores and decide based on thresholds.
- Write decision and outbox record in the same PostgreSQL transaction.
- A relay reads the outbox and publishes to Kafka. Downstream consumers processing slowly don’t affect the API.
4. Data Model
You need these dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
PostgreSQL schema:
CREATE TABLE fraud_decision (
transaction_id VARCHAR(64) PRIMARY KEY,
card_id VARCHAR(64) NOT NULL,
amount NUMERIC(18,2) NOT NULL,
decision VARCHAR(10) NOT NULL,
score INT NOT NULL,
reasons TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
CREATE TABLE fraud_outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_key VARCHAR(64) NOT NULL, -- card_id, used as Kafka key
payload JSONB NOT NULL,
published BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX idx_outbox_unpublished ON fraud_outbox (id) WHERE NOT published;
The table is partitioned by day. Thanks to that, deleting old data is just DROP partition, not DELETE hundreds of millions of rows.
Note: With partitioned tables, the primary key must include the partition column. In practice you’d use
PRIMARY KEY (transaction_id, created_at). To look up idempotency quickly by justtransactionId, keep a separate smallidempotency_keytable with TTL. Here the article simplifies for readability.
Hot state lives in Redis:
velocity:card:{cardId} ZSET member=transactionId, score=epochMillis TTL 10 minutes
profile:card:{cardId} HASH avgAmount, homeCountry TTL 30 days
5. API
Request, response, and shared data types:
public record TransactionRequest(
String transactionId, String cardId, String merchantId,
BigDecimal amount, String currency, String country, Instant occurredAt) {}
public enum Decision { APPROVE, REVIEW, DECLINE }
public record RuleHit(String code, int score) {
public static RuleHit none() { return new RuleHit(null, 0); }
public boolean matched() { return code != null; }
}
public record FraudResult(String transactionId, Decision decision, int score, List<String> reasons) {}
The controller just forwards:
@RestController
@RequestMapping("/api/v1/fraud")
public class FraudController {
private final FraudScoringService scoringService;
public FraudController(FraudScoringService scoringService) {
this.scoringService = scoringService;
}
@PostMapping("/score")
public FraudResult score(@RequestBody TransactionRequest request) {
return scoringService.score(request);
}
}
A sample call and result:
curl -X POST localhost:8080/api/v1/fraud/score -H 'Content-Type: application/json' -d '{
"transactionId":"tx-1001","cardId":"card-42","merchantId":"m-7",
"amount":15000000,"currency":"VND","country":"NG","occurredAt":"2026-10-12T08:00:00Z"}'
{"transactionId":"tx-1001","decision":"REVIEW","score":55,"reasons":["HIGH_RISK_COUNTRY","AMOUNT_SPIKE"]}
6. Rule Engine
Each rule is an independent bean. Adding a new rule doesn’t touch old code:
public interface FraudRule {
RuleHit evaluate(TransactionRequest tx);
}
@Component
public class HighRiskCountryRule implements FraudRule {
private static final Set<String> RISKY = Set.of("NG", "KP", "IR");
@Override
public RuleHit evaluate(TransactionRequest tx) {
return RISKY.contains(tx.country()) ? new RuleHit("HIGH_RISK_COUNTRY", 30) : RuleHit.none();
}
}
Service sums scores, decides, and saves results:
@Service
public class FraudScoringService {
private final List<FraudRule> rules;
private final DecisionRepository repository;
public FraudScoringService(List<FraudRule> rules, DecisionRepository repository) {
this.rules = rules;
this.repository = repository;
}
public FraudResult score(TransactionRequest tx) {
Optional<FraudResult> existing = repository.find(tx.transactionId());
if (existing.isPresent()) {
return existing.get(); // retry → return old decision
}
List<RuleHit> hits = rules.stream().map(r -> r.evaluate(tx)).filter(RuleHit::matched).toList();
int score = Math.min(100, hits.stream().mapToInt(RuleHit::score).sum());
Decision decision = score >= 80 ? Decision.DECLINE : score >= 40 ? Decision.REVIEW : Decision.APPROVE;
FraudResult result = new FraudResult(tx.transactionId(), decision, score,
hits.stream().map(RuleHit::code).toList());
return repository.saveOrGetExisting(tx, result);
}
}
Spring automatically injects every FraudRule bean into List<FraudRule>. Score is capped at 100. Thresholds 40 and 80 should be configuration so the risk team can adjust without redeploying.
7. Hard Problems and Solutions
7.1. Contention: Velocity Check on Hot Keys
The rule “more than 5 transactions in 60 seconds on one card” sounds simple. Fraudsters love to fire parallel requests. If you read the count then write, two requests both read 4 and both get approved. That’s a classic race condition.
The solution is to pack “remove old, add new, count” into a Lua script. Redis runs the script atomically:
@Component
public class VelocityRule implements FraudRule {
private static final RedisScript<Long> SCRIPT = RedisScript.of("""
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2]))
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[3])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return redis.call('ZCARD', KEYS[1])
""", Long.class);
private final StringRedisTemplate redis;
public VelocityRule(StringRedisTemplate redis) { this.redis = redis; }
@Override
public RuleHit evaluate(TransactionRequest tx) {
try {
Long count = redis.execute(SCRIPT, List.of("velocity:card:" + tx.cardId()),
String.valueOf(tx.occurredAt().toEpochMilli()), "60000", tx.transactionId());
return count != null && count > 5 ? new RuleHit("VELOCITY_EXCEEDED", 50) : RuleHit.none();
} catch (DataAccessException e) {
return new RuleHit("VELOCITY_UNAVAILABLE", 20); // degrade, don't crash API
}
}
}
Three details worth noting:
- Member is
transactionId. When the gateway retries,ZADDonly updates the score of the old member, doesn’t increase the count. The rule is self-idempotent. - Use
occurredAtinstead of server time. Results won’t drift when pods’ clocks are skewed. - Redis Cluster partitions by key. Each card is a separate key so load spreads evenly. A truly hot key might be by merchant (
velocity:merchant:*) for a big merchant. For such cases, split the key into time buckets, or count approximately in local memory then sync periodically.
7.2. Consistency: Idempotency and Outbox
Two consistency failures are common:
- Two requests with the same
transactionIdarrive simultaneously. Both fail to find an old decision. - DB write succeeds but Kafka publish fails, or vice versa.
Problem (1) is solved by a unique constraint and ON CONFLICT. Problem (2) is solved by outbox in the same transaction:
@Repository
public class JdbcDecisionRepository implements DecisionRepository {
private final JdbcTemplate jdbc;
private final ObjectMapper mapper;
public JdbcDecisionRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
this.jdbc = jdbc;
this.mapper = mapper;
}
@Override
@Transactional
public FraudResult saveOrGetExisting(TransactionRequest tx, FraudResult result) {
int inserted = jdbc.update("""
INSERT INTO fraud_decision (transaction_id, card_id, amount, decision, score, reasons)
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (transaction_id) DO NOTHING
""", tx.transactionId(), tx.cardId(), tx.amount(), result.decision().name(),
result.score(), result.reasons().toArray(String[]::new));
if (inserted == 0) {
return find(tx.transactionId()).orElseThrow(); // another request won
}
jdbc.update("INSERT INTO fraud_outbox (aggregate_key, payload) VALUES (?, ?::jsonb)",
tx.cardId(), toJson(result));
return result;
}
// find(...) and toJson(...) omitted for brevity
}
inserted == 0 means a parallel request wrote first. Then you return that request’s decision. So two calls always get the same answer.
The relay reads the outbox in batches and publishes with key cardId. All events for one card go to the same partition so they stay ordered:
@Scheduled(fixedDelay = 200)
@Transactional
public void relay() {
List<Map<String, Object>> rows = jdbc.queryForList("""
SELECT id, aggregate_key, payload::text AS payload FROM fraud_outbox
WHERE NOT published ORDER BY id LIMIT 500 FOR UPDATE SKIP LOCKED""");
for (Map<String, Object> row : rows) {
kafka.send("fraud.decisions", (String) row.get("aggregate_key"), (String) row.get("payload")).join();
jdbc.update("UPDATE fraud_outbox SET published = true WHERE id = ?", row.get("id"));
}
}
FOR UPDATE SKIP LOCKED lets multiple relay instances run in parallel without stepping on each other. This pattern guarantees at-least-once. So downstream consumers must also be idempotent, e.g., use transactionId as the key when creating an investigation case.
7.3. Burst Load
Peak load is 10x average. Three techniques help you weather a spike:
Tight time budgets. Set short timeouts on Redis and DB so one slow component doesn’t drag the API beyond 50ms:
spring.data.redis.timeout=15ms
spring.datasource.hikari.connection-timeout=20
spring.datasource.hikari.maximum-pool-size=30
spring.threads.virtual.enabled=true
Virtual threads in Java 21 let you keep thousands of I/O-waiting requests without draining Tomcat’s thread pool.
Clear degradation policy. When Redis fails, VelocityRule returns VELOCITY_UNAVAILABLE with 20 points. That’s a business choice, not a technical one. Fail-open (let through) keeps revenue. Fail-closed (block) keeps safety. Lock this policy in with the risk team beforehand and metric every degrade.
Push heavy work outside the hot path. Complex ML models, third-party service calls, or daily feature computing all run on Kafka consumers. Kafka absorbs load spikes: a consumer lagging a few minutes doesn’t hurt payments. Results then get written back to profile:card:* for later transactions to use.
8. Testing Main Behaviors
This test checks decision logic and idempotency. It uses fake rules and in-memory repository, no Redis or DB:
class FraudScoringServiceTest {
static class InMemoryRepo implements DecisionRepository {
final Map<String, FraudResult> store = new ConcurrentHashMap<>();
public Optional<FraudResult> find(String id) { return Optional.ofNullable(store.get(id)); }
public FraudResult saveOrGetExisting(TransactionRequest tx, FraudResult r) {
FraudResult prev = store.putIfAbsent(tx.transactionId(), r);
return prev != null ? prev : r;
}
}
private TransactionRequest tx(String id) {
return new TransactionRequest(id, "card-42", "m-7", new BigDecimal("15000000"),
"VND", "NG", Instant.parse("2026-10-12T08:00:00Z"));
}
@Test
void declinesWhenScoreReachesThreshold() {
List<FraudRule> rules = List.of(t -> new RuleHit("HIGH_RISK_COUNTRY", 30),
t -> new RuleHit("VELOCITY_EXCEEDED", 50));
var service = new FraudScoringService(rules, new InMemoryRepo());
FraudResult result = service.score(tx("tx-1"));
assertThat(result.decision()).isEqualTo(Decision.DECLINE);
assertThat(result.score()).isEqualTo(80);
assertThat(result.reasons()).containsExactly("HIGH_RISK_COUNTRY", "VELOCITY_EXCEEDED");
}
@Test
void retryReturnsSameDecisionEvenIfRulesChange() {
AtomicInteger calls = new AtomicInteger();
FraudRule flaky = t -> calls.incrementAndGet() == 1 ? RuleHit.none() : new RuleHit("X", 90);
var service = new FraudScoringService(List.of(flaky), new InMemoryRepo());
FraudResult first = service.score(tx("tx-2"));
FraudResult retry = service.score(tx("tx-2"));
assertThat(first.decision()).isEqualTo(Decision.APPROVE);
assertThat(retry).isEqualTo(first);
}
}
The second test is crucial. If external state changes between two calls, a retry should still not flip from APPROVE to DECLINE. With Lua script, you should add an integration test with Testcontainers. Fire 20 parallel requests for the same card and verify only requests from the 6th onward get tagged VELOCITY_EXCEEDED.
9. Common Mistakes and When Not to Do This
- Call ML models synchronously on the payment path without timeout and fallback. One slow model call is 2 seconds and the whole payment system lags.
- Use server time for velocity window. Pod clock skew a few seconds and your count is wrong.
- Publish Kafka directly in service instead of through outbox. You’ll have “phantom” decisions: returned to gateway but downstream doesn’t know.
- Count velocity with read-then-write (
GETthenINCR). Fraudsters fire parallel requests and skip past. - Treat Redis as the source of truth. Redis only holds hot state and can be lost. The source is PostgreSQL and Kafka. You must be able to rebuild the profile from them.
Note: If your throughput is only tens of TPS and you have no latency SLA, this architecture is overkill. A service reading and writing PostgreSQL with a few indexed
COUNT(*)queries bycard_id, created_atis enough. Only add Redis, outbox, and Kafka when metrics show you truly need them.
10. Conclusion
This article designed a real-time fraud detection system following the principle of separating the hot path from the cold path. The synchronous API runs only fast rules, reads and writes Redis using atomic Lua scripts, and writes decision and outbox in one transaction. Heavier work is pushed through Kafka.
Three hard problems have three concrete solutions. Contention is handled with atomic operations and keys per card. Consistency relies on ON CONFLICT plus transactional outbox. Burst load is controlled by tight timeouts, clear degradation policy, and a load-absorbing queue. From this framework, you can add new rules, plug in ML models on the consumer side, then write scores back to Redis for later transactions to use.