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;
}
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;
}
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;
}
}
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 };
}
}
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);
});
});
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.
Aegis Fraud Core
A high throughput, modular transaction risk scoring engine designed for real time threat mitigation.
NestJS Framework •
TypeScript Core •
Strategy Pattern Architecture •
Strict Payload Validation
Architectural Highlights •
Directory Layout •
Getting Started •
API Specification
Architectural Highlights
Aegis Fraud Core decouples complex evaluation logic from execution pipelines to maintain an extensible, production-grade security architecture.
Risk calculations are isolated into self-contained strategy classes adhering to a strict interface contract. The engine loops through rules uniformly without tight coupling. Enforces strict payload shape and structural integrity right at the HTTP layer using global pipes. Malformed data is dropped before reaching core logic. Tracks multi-transaction profiles via low-latency state tracking to intercept distributed behavioral threat vectors, such as velocity attacks.
Strategy Pattern
Front-Door Interception
Contextual Analytics
Directory Layout
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
Backend Software Engineer
Backend Software Engineer. Building secure, scalable and well architected server systems.
I specialize in building scalable backend systems and modern fullstack web applications. I love turning ideas into real, deployed, working products.
- 🖥️ See my portfolio at MyPortfolio
- ✉️ You can contact me at peacemelodi1@gmail.com

Top comments (0)