The first fraud rule anyone writes usually looks harmless. A quick check on the transaction amount, maybe an if statement, done. Then a second rule gets added for new recipients. Then a third for transaction frequency. Within a few weeks, one method has quietly grown into a long chain of conditions, each one dependent on the ones before it, and nobody feels confident touching it anymore. I have seen this exact pattern happen more than once, and it is worth talking through before it happens to you, because the fix is simple if you catch it early, and genuinely painful to unwind once it has spread across a real production system. First, stop adding rules as more conditions in one function The instinct when a new fraud rule is needed is almost always to open the existing validation method and add one more if statement. validateTransaction(dto: CreateTransactionDto) { let score = 0; if (dto.amount > 10000) score += 100; if (this.isNewRecipient(dto.recipientId)) score += 35; // The next rule means editing this same method again return score; } Enter fullscreen mode Exit fullscreen mode This works for the first two or three rules. The trouble is that this single method is now responsible for every fraud rule the business will ever need, forever, and every new rule means going back into a function that keeps growing and getting harder to reason about safely. A mistake in one rule risks breaking every rule sitting beside it in the same block of code. The better approach is giving every fraud rule its own isolated class, all following the same shared contract. export interface FraudRule { readonly name: string; execute(transaction: CreateTransactionDto, history: any[]): number; } Enter fullscreen mode Exit fullscreen mode export class VelocityRule implements FraudRule { readonly name = 'Velocity Check'; execute(transaction: CreateTransactionDto, history: any[]): number { const recentTransactions = history.filter( (tx) => tx.senderId === transaction.senderId, ); return recentTransactions.length >= 3 ? 60 : 0; } } Enter fullscreen mode Exit fullscreen mode Each rule only knows about its own small piece of logic. A bug inside the velocity check cannot reach into or affect the rule checking transaction amounts, since they no longer share any code at all. Second, keep the part that runs the rules completely generic Once each rule is its own class, the temptation is still there to write a service that knows about each rule by name, calling one, then the next, then the next. That still means editing the core service every time a new rule is added, which defeats most of the benefit of separating them in the first place. The fix is a service that simply loops through whatever rules exist, without needing to know anything about what any individual rule actually checks. @Injectable() export class TransactionsService { private readonly transactionHistory: any[] = []; private readonly rules: FraudRule[] = [ new HighAmountRule(), new NewRecipientRule(), new VelocityRule(), ]; validateTransaction(dto: CreateTransactionDto) { let totalScore = 0; const flaggedBy: string[] = []; for (const rule of this.rules) { const score = rule.execute(dto, this.transactionHistory); if (score > 0) { totalScore += score; flaggedBy.push(rule.name); } } const action = totalScore >= 70 ? 'blocked' : totalScore >= 35 ? 'review' : 'allowed'; return { totalScore, action, flaggedBy }; } } Enter fullscreen mode Exit fullscreen mode Adding a new fraud rule from this point on means writing a new class and dropping it into that array. The loop itself never changes, no matter how many rules eventually exist. This is the practical benefit of keeping the engine open to new rules without ever needing to modify how it actually runs them. Third, test each rule completely on its own The last mistake I would warn against is testing fraud detection the same way the old tangled function was written, one enormous test that sets up a complicated transaction and checks the final combined score. That kind of test tells you something failed, but not which rule caused it, or why. Since every rule is now its own small class with one clear job, each one can be tested in complete isolation. describe('VelocityRule', () => { it('flags a sender with three or more recent transactions', () => { const rule = new VelocityRule(); const history = [ { senderId: 'user1' }, { senderId: 'user1' }, { senderId: 'user1' }, ]; const score = rule.execute({ senderId: 'user1' } as any, history); expect(score).toBe(60); }); it('does not flag a sender with fewer than three recent transactions', () => { const rule = new VelocityRule(); const history = [{ senderId: 'user1' }]; const score = rule.execute({ senderId: 'user1' } as any, history); expect(score).toBe(0); }); }); Enter fullscreen mode Exit fullscreen mode A test like this needs no elaborate setup and checks exactly one thing. Multiply that across every rule in the system, and the whole fraud engine becomes something a team can trust, verify, and extend with confidence, rather than something everyone is quietly afraid to touch. For anyone who wants to see this exact pattern applied in a working example rather than just isolated snippets, here is a small fraud detection engine built around it. PeaceMelodi / aegis-fraud-core A high throughput, modular risk scoring engine built with NestJS. Utilizes the Strategy Pattern to execute completely decoupled financial threat analysis metrics and evaluation pipelines in real time. Architectural Highlights Aegis Fraud Core decouples complex evaluation logic from execution pipelines to maintain an extensible, production-grade security architecture. Strategy Pattern Risk calculations are isolated into self-contained strategy classes adhering to a strict interface contract. The engine loops through rules uniformly without tight coupling. Front-Door Interception Enforces strict payload shape and structural integrity right at the HTTP layer using global pipes. Malformed data is dropped before reaching core logic. Contextual Analytics Tracks multi-transaction profiles via low-latency state tracking to intercept distributed behavioral threat vectors, such as velocity attacks. Directory Layout Click to expand file tree src/ ├── main.ts # Entry point binding global validation pipes ├── app.module.ts └── transactions/… The bigger picture None of these three things require anything exotic, a shared interface, a generic loop, and small isolated tests. What they prevent is the slow, quiet decay that turns fraud detection into the one file nobody wants to open. NestJS makes this structure genuinely easy to set up from the start, since dependency injection and clean module boundaries are already built around keeping responsibilities separate rather than tangled together. Fraud rules change constantly in any real fintech system, new patterns get discovered, thresholds shift, entirely new checks get added months later. A system built this way absorbs that change one small file at a time, instead of one increasingly fragile function trying to hold everything at once. If your team is building fraud detection or any rules based decision logic and wants it structured to actually survive years of changes rather than just the first few weeks, that is exactly the kind of problem I would be glad to help with. I am Peace Melodi, a backend software engineer. If you want your business to scale big, comfortably handling millions of users without breaking, with strong scalability and security in place, feel free to reach out. LinkedIn: https://www.linkedin.com/in/melodi-peace-406494368
Three things I would tell any NestJS developer before they write their first banking fraud detection rule
Full Article
📰 Original Source
Read full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.