DEV Community

Cover image for What Your NestJS Backend Should Notice Before a Human Ever Does
Peace Melodi
Peace Melodi

Posted on

What Your NestJS Backend Should Notice Before a Human Ever Does

A single transaction rarely tells you anything is wrong. A customer moves five hundred dollars, that is normal. The same customer moves five hundred dollars nine times in an hour, to nine different accounts that have never received money from them before, that is not normal, and no human is watching closely enough, in real time, to catch that pattern the moment it happens. This is the actual job of anti money laundering monitoring, not catching one suspicious transaction, but noticing a pattern across many transactions before a person ever gets the chance to look.

Banks and fintechs are required to run this kind of monitoring, and compliance and financial crime hiring has grown sharply as regulatory pressure has increased. The technical challenge underneath that requirement is real and specific, a backend has to track behavior over time, not just validate a single request in isolation, and it has to do this consistently, without depending on a human noticing something later.

Why single transaction checks are not enough

A fraud check on one transaction can catch an obviously wrong amount or an obviously wrong account. AML monitoring is a different problem, since the individual transaction can look completely ordinary, and the concerning part only becomes visible when you look at a sequence of transactions together. Structuring deposits to stay just under a reporting threshold, moving money rapidly between accounts that rarely interact, or a sudden change in a customer's normal transaction pattern are all things that only become visible over a window of time.

This means the backend needs a way to track recent transaction history per customer, and evaluate that history against defined patterns, not just check the transaction currently in front of it.

Structuring a monitoring rule around a transaction history window

A dedicated service that pulls a customer's recent transaction history gives every rule a consistent, shared source of the same data, rather than each rule quietly fetching its own version of recent activity.

@Injectable()
export class TransactionHistoryService {
  constructor(
    @InjectRepository(Transaction)
    private readonly transactionRepo: Repository<Transaction>,
  ) {}

  async getRecentTransactions(
    customerId: string,
    windowMinutes: number,
  ): Promise<Transaction[]> {
    const since = new Date(Date.now() - windowMinutes * 60000);

    return this.transactionRepo.find({
      where: {
        customerId,
        createdAt: MoreThan(since),
      },
      order: { createdAt: 'DESC' },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Every monitoring rule can now ask for the same shared window of history, rather than each rule independently deciding what counts as recent.

Detecting structuring, transactions kept just under a threshold

A common pattern regulators specifically watch for is a customer breaking one large transaction into several smaller ones, each staying just under a reporting threshold. A rule built around the shared history service can check for this directly.

@Injectable()
export class StructuringRule {
  constructor(private readonly historyService: TransactionHistoryService) {}

  async evaluate(customerId: string): Promise<AmlFinding | null> {
    const reportingThreshold = 10000;
    const recent = await this.historyService.getRecentTransactions(customerId, 60);

    const nearThresholdCount = recent.filter(
      (transaction) =>
        transaction.amount >= reportingThreshold * 0.8 &&
        transaction.amount < reportingThreshold,
    ).length;

    if (nearThresholdCount >= 3) {
      return {
        rule: 'structuring',
        severity: 'high',
        reason: 'Multiple transactions just under the reporting threshold within an hour',
      };
    }

    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

This kind of pattern is invisible if you only look at each transaction on its own. It only becomes visible once you look at the shared window of recent activity together.

Detecting a sudden change from a customer's normal behavior

A customer who normally sends small, occasional transfers, then suddenly sends a much larger transfer to a brand new recipient, represents a real shift worth flagging, even if no single rule about thresholds is triggered.

@Injectable()
export class BehaviorShiftRule {
  constructor(private readonly historyService: TransactionHistoryService) {}

  async evaluate(customerId: string): Promise<AmlFinding | null> {
    const recent = await this.historyService.getRecentTransactions(customerId, 43200);

    if (recent.length < 5) {
      return null;
    }

    const averageAmount =
      recent.reduce((sum, transaction) => sum + transaction.amount, 0) / recent.length;

    const latest = recent[0];

    if (latest.amount > averageAmount * 5) {
      return {
        rule: 'behavior_shift',
        severity: 'medium',
        reason: 'Latest transaction is far larger than this customer\'s typical amount',
      };
    }

    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Keeping the comparison relative to each customer's own history, rather than a single fixed number for everyone, means the rule stays meaningful across customers with very different normal spending patterns.

Running every rule together and recording what was found

A dedicated monitoring service brings every rule together, runs them against the same customer, and records any findings in a way that can be reviewed and audited later.

@Injectable()
export class AmlMonitoringService {
  constructor(
    private readonly structuringRule: StructuringRule,
    private readonly behaviorShiftRule: BehaviorShiftRule,
    @InjectRepository(AmlFinding)
    private readonly findingRepo: Repository<AmlFinding>,
  ) {}

  async monitorCustomer(customerId: string): Promise<AmlFinding[]> {
    const findings = (
      await Promise.all([
        this.structuringRule.evaluate(customerId),
        this.behaviorShiftRule.evaluate(customerId),
      ])
    ).filter((finding): finding is AmlFinding => finding !== null);

    for (const finding of findings) {
      await this.findingRepo.save({
        customerId,
        ...finding,
        detectedAt: new Date(),
      });
    }

    return findings;
  }
}
Enter fullscreen mode Exit fullscreen mode

Recording every finding, even ones that turn out to be a false alarm after review, matters as much as detecting them, since a regulator or auditor needs to see that monitoring is actually running, not just that it occasionally catches something.

Running this on a schedule, not just at the moment of a transaction

Some patterns, like structuring, only fully reveal themselves once enough transactions have accumulated, which means checking only at the moment of a single transaction is not enough. Running monitoring on a schedule catches patterns that built up gradually.

@Injectable()
export class AmlScheduler {
  constructor(
    private readonly monitoringService: AmlMonitoringService,
    private readonly customerService: CustomerService,
  ) {}

  @Cron('*/15 * * * *')
  async runScheduledMonitoring(): Promise<void> {
    const activeCustomers = await this.customerService.findRecentlyActive();

    for (const customer of activeCustomers) {
      await this.monitoringService.monitorCustomer(customer.id);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Running this every fifteen minutes across recently active customers means a pattern building up across several transactions gets caught within a reasonable window, rather than only being noticed if someone happens to look later.

The bigger picture

None of these individual rules are complicated on their own. What actually matters is the structure underneath them, a shared way to pull a customer's recent history, rules that evaluate that history rather than a single transaction, and a schedule that keeps checking even when nothing happens to trigger a check at the exact moment of a transaction. NestJS gives you a clean place to keep each of these pieces separate, so new rules can be added later without disturbing the ones already running.

No rule set catches everything, and people trying to avoid detection keep adapting, but a system that actually looks at behavior over time, consistently and automatically, gives a bank or fintech a real chance of noticing something before a human ever has to go looking for it.

If you are building compliance or monitoring infrastructure and want this handled with real technical rigor, this is exactly the kind of work I focus on.

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
GitHub: https://github.com/PeaceMelodi

Top comments (0)