← all posts

.NET

Building a Simple Fraud-Detection Rule in .NET

A walkthrough of one real fraud rule: the interface, the verdict type, the repository query behind it, and why the whole thing is about 20 lines of actual logic.

Building a Simple Fraud-Detection Rule in .NET

Fraud detection sounds like it should be complicated. The first real rule I wrote is about twenty lines, and most of the work went into the shape around it rather than the logic itself. Here's the whole thing, piece by piece.

Start with the contract

Every rule implements one interface. That's what makes rules addable without touching the code that runs them:

public interface IFraudRule
{
    string RuleName { get; }
    Task<FraudVerdict> EvaluateAsync(Payment payment, CancellationToken ct = default);
}

Two decisions worth calling out. RuleName exists because when a payment gets rejected, you need to know which rule did it, and that string ends up stored with the flag. And EvaluateAsync returns a verdict rather than a bool, because "suspicious" on its own is useless when someone asks why later.

The verdict type

public record FraudVerdict(bool IsSuspicious, string? Details)
{
    public static FraudVerdict Clean { get; } = new(false, null);
    public static FraudVerdict Suspicious(string details) => new(true, details);
}

Clean is a single cached instance since it carries no information, while Suspicious requires you to pass details. You can't construct a suspicious verdict without explaining it, which is the point.

The rule itself

The pattern I wanted to catch is card testing: someone with a list of stolen card numbers running small transactions to find out which cards are still live before actually spending them. That looks like repeated declines on the same card in a short window.

public class DeclineVelocityRule(IPaymentReadOnlyRepository readRepo) : IFraudRule
{
    private const int MaxRecentDeclines = 3;
    private static readonly TimeSpan Window = TimeSpan.FromHours(24);

    public string RuleName => "DeclineVelocity";

    public async Task<FraudVerdict> EvaluateAsync(Payment payment, CancellationToken ct = default)
    {
        var declines = await readRepo.CountRecentDeclinedAttempts(
            payment.CardBin, payment.CardLast4, Window);

        return declines >= MaxRecentDeclines
            ? FraudVerdict.Suspicious($"Card reached {declines} declined attempts within {Window.TotalHours}h.")
            : FraudVerdict.Clean;
    }
}

That's the entire rule. Count recent declines for this card, compare against a threshold, return a verdict with an explanation.

The parts that aren't the logic

Three things in there matter more than the comparison itself.

The rule identifies a card by BIN and last four digits, not by a full card number, because the full number is never stored anywhere. That's enough to recognise the same card across transactions without holding data I shouldn't have.

It depends on a read-only repository interface rather than a DbContext. The rule doesn't know or care whether the count comes from SQLite, SQL Server, or a cache, and that's what makes it testable without a database.

The threshold and the window are constants at the top instead of magic numbers buried in the comparison. Three declines in 24 hours is a judgment call, not a fact, and it's the kind of number that gets tuned once you see real traffic, so it should be obvious where to change it.

Where it runs

The rule is evaluated before any payment gateway is contacted. A card that already looks like it's being tested costs nothing to reject, and there's no reason to spend a gateway call finding out what you already know from the history.

What I'd change with real data

The threshold is a guess. Three declines in 24 hours seems reasonable, but a real system would tune that against actual traffic, and probably vary it by amount or by how established the customer is. A brand new customer failing three times is a very different signal from a regular failing three times because their card just expired.

That's the part a study project can't really teach me. What it can teach is the shape: a contract, a verdict that explains itself, and thresholds sitting somewhere obvious enough to change when you learn they're wrong.