Building FamPay's first fraud and risk engine
A few years ago I built the first version of the fraud and risk engine at FamPay. FamPay is a payments company, and payments companies live and die by trust β so once you're processing anywhere near serious transaction volume, fraud and risk stop being a "nice to have" and become one of the core systems you need running underneath everything.
Here's the story of how that system came together, and the storage problem that nearly broke it.
π― The requirement
The ask from the product side was fairly simple to state and fairly hard to build: the ops and investigation teams were the ones who actually saw fraud patterns emerge β specific kinds of user behavior that, in hindsight, were clear signals something bad was happening. What they needed was a way to configure those patterns into the system themselves, so that the moment a user's behavior matched one, it would be caught deterministically and raised β either for the system to act on automatically, or for a fraud and risk agent to step in and take the next step.
The key word there is deterministic. This wasn't a scoring model or a black box β it needed to be a system where you could look at a triggered case and know exactly why it fired.
π§© Rules, Criteria, and Actions
We modeled this as three entities.
A Rule is the top-level thing you configure β it represents a pattern worth watching for. A rule is made up of a set of Criteria, which are the atomic building blocks. A criterion is just a simple condition: something like amount > 100, or transaction_channel = "UPI".
Criteria within a rule are split into mandatory and optional. A rule triggers when all of its mandatory criteria are satisfied, and at least one of its optional criteria (if any exist) is also satisfied.
Once a rule triggers, it needs to actually do something β that's where Actions come in. A rule can be linked to multiple actions: send the user a notification, block them, suspend them, tag them, assign a severity level, and so on. Rules decide when something matters; actions decide what happens next.
Here's roughly how the three entities relate to each other:
βββββββββββββββββββββββββ
β RULE β
β "flag this pattern" β
βββββββββββββ¬ββββββββββββββ
β
βββββββββββββββββββΌββββββββββββββββββ
β has criteria β fires
βΌ βΌ
βββββββββββββββββββββββββββ βββββββββββββββββββββββββ
β CRITERION β β ACTION β
β field op value β β π notify β
β e.g. amount > 100 β β π« block β
β β β βΈοΈ suspend β
β β mandatory (ALL must β β π·οΈ tag β
β pass) β β β οΈ assign severity β
β β optional (ANY can β βββββββββββββββββββββββββ
β pass) β
βββββββββββββββββββββββββββ
π Composing patterns out of patterns
Real fraud patterns are rarely a single flat condition. Ops would come to us with things like "flag it if a user does this specific type of activity more than five times in 24 hours" β which is really a pattern built on top of another pattern.
To support that, we let a rule reference another rule as one of its criteria. So you could define Rule 1 as transaction_channel = "UPI", and then define Rule 2 as "Rule 1 occurred within the last 24 hours." Because a rule-reference is just another criterion, this composes recursively β you can build fairly sophisticated, layered fraud logic entirely out of the same small set of primitives, without ever writing new code for each new pattern.
RULE 1 RULE 2
βββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββ
β transaction_channel ββββββββββββ criterion: "Rule 1 triggered β
β = "UPI" β used as β within last 24h" β
βββββββββββββββββββββββββ a β criterion: occurrence_count > 5 β
criterionβββββββββββββββββββββββββββββββββββββ
β
β could itself be referenced by
βΌ
RULE 3, RULE 4, ... π (recursively)
π’ The first bottleneck: ClickHouse
For the first version, all of this data lived in ClickHouse. It was an easy choice at the time β quick to set up, open source, well documented, and it had automatic archival built in, which mattered because our data volume was only ever going to grow.
We were doing about 6 million transactions a day, peaking around 6,000 transactions a minute. Every rule's criteria got translated into filters and run as a query against ClickHouse, directly over the raw event data. We were storing the entire event payload as columns β if a transaction event had 100 params, we had a table with 100 columns, and rules queried against it with filters built from their criteria.
That worked fine at first. But as ops kept adding more rules, and rules kept getting more complex β more criteria, more nested rule-references β the query cost kept climbing. ClickHouse is columnar, so the more filters you stack on a wide table, the more data it has to scan and filter to get you an answer. Our target SLA for a rule's query to execute was 10ms. At peak load, we were seeing 500-800ms.
That's not a small miss. It meant the system was getting slower exactly when it mattered most β during high transaction volume, which is also when fraud activity tends to spike. π¬
event { amount, channel, device_id, location, ...97 more fields }
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββ
β ClickHouse β wide table β target SLA: π― 10ms
β 100 columns, scanned + filtered per query β actual p99: π’ 500-800ms
ββββββββββββββββββββββββββββββββββββββββββββ
β‘ Decoupling complexity from cost
The core problem was that query cost scaled with rule complexity β the more conditions a rule had, the more expensive it was to evaluate, every single time. We needed an approach where added complexity in the rule definition didn't translate into added computation at query time.
The fix was to precompute. Instead of evaluating a rule's conditions against raw event data every time someone queried it, we hashed each rule's condition set. Whenever a user's activity satisfied a rule, we wrote a row into a new table β activity_rule_trigger β with a constant schema: which customer, which rule fired, when it fired, plus some metadata. That's it, regardless of how complicated the underlying rule was.
write path (once, when the event happens)
βββββββββββ hash rule's ββββββββββββββββββββββββββββββ
β event β βββconditionsβββΊβ activity_rule_trigger β
βββββββββββ β customer | rule | ts | meta β
β (constant schema, always) β
ββββββββββββββββ¬ββββββββββββββββ
β
read path (every time a velocity rule asks) β
"did Rule 1 fire > 5x in 24h?" ββββββββββββββ
cheap, flat lookup β no matter
how nested the rule logic is π
Velocity-style rules β the "this happened more than 5 times in 24 hours" pattern β now just queried this flat table instead of recomputing the underlying condition from scratch. The expensive part happened once, at write time, when the event first came in. Everything downstream became cheap, constant-time lookups.
The result: p99 latency went from 800ms down to 80ms β a 10x improvement.
before π’π’π’π’π’π’π’π’ 800ms
after π 80ms
There was a second, unplanned benefit. Because rule triggers were stored generically β hash, customer, timestamp, metadata β the system became stateless and schemaless with respect to activity types. As FamPay kept adding new kinds of activity to track β logins π, SIM binding π±, bank account linking π¦, and more β none of them needed to be registered in code or given a dedicated schema. If an event came in and its conditions hashed to a configured rule, it just worked. β¨
π Looking back
What I like most about this system, in retrospect, isn't any single technical decision β it's that the constraints pushed us toward a small set of composable primitives (Rule, Criterion, Action) that ops could combine into arbitrarily sophisticated fraud logic without needing an engineer in the loop for every new pattern. The ClickHouse bottleneck was a real scare, but it forced a rearchitecture that made the system both faster and more extensible at the same time β which doesn't happen often.