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, or DECLINE, 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 REVIEW decisions 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:

  1. Idempotent: the payment gateway may retry the same transaction. Each retry must receive the same decision.
  2. No lost events: once a decision is returned, it must be recorded and published.
  3. Explainable: every DECLINE must 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:

  1. API receives request and checks if there’s already a decision for transactionId.
  2. Run the rules. Rules needing state (velocity) read and write Redis using a single atomic command.
  3. Sum scores and decide based on thresholds.
  4. Write decision and outbox record in the same PostgreSQL transaction.
  5. 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 just transactionId, keep a separate small idempotency_key table 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, ZADD only updates the score of the old member, doesn’t increase the count. The rule is self-idempotent.
  • Use occurredAt instead 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:

  1. Two requests with the same transactionId arrive simultaneously. Both fail to find an old decision.
  2. 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 (GET then INCR). 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 by card_id, created_at is 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.

system-designspring-bootrediskafkafraud-detection