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' },
});
}
}
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;
}
}
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;
}
}
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;
}
}
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);
}
}
}
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 (4)
The
AmlFindingaudit object is the right instinct — regulators want to see the exact transaction set and rule version that produced a flag, not just a boolean. That kind of trail is what separates a monitoring system from a detection script.One thing worth flagging that connects to the scheduled evaluation pattern: when transaction data comes from external bank APIs rather than your own payment rails, the pending→booked transition creates a subtle false-positive risk for window-based rules.
A structuring rule counting transactions in the 80–100% threshold band could double-count if a transaction lands as
pending, enters the history window, then transitions tobookedwith a slightly different amount or timestamp. Three real structuring deposits could look like six.The practical fix is to key the history window on a stable bank-provided transaction identifier and treat pending→booked as an UPSERT rather than an insert. For the ~15–20% of bank transactions that arrive without a clean transaction ID, a deterministic hash of (account, amount, counterparty, date bucket) works as a fallback — but you need to exclude volatile fields like
bookingDatefrom the hash input, since some bank APIs update it during the pending→booked transition.Good catch, honestly, I did not think this through carefully enough when writing it. You are right that pending to booked is exactly the kind of transition that quietly wrecks a window based rule if the identifier is not stable. Counting the same real deposit twice because it showed up once as pending and once as booked would trigger a false structuring flag, and that is arguably worse than missing a real one, since it burns an analyst's time and slowly makes people stop trusting the alerts. Keying the window on a stable transaction id and treating pending to booked as an upsert instead of a fresh insert is the right fix. And the fallback hash idea for transactions with no clean id is smart, especially flagging that bookingDate needs to stay out of the hash since some providers update it during that same transition. That is exactly the kind of detail that would slip through unnoticed until it quietly broke something in production. Thanks for laying it out this clearly.
The shared history service is the right spine here — one definition of "recent" instead of each rule inventing its own is what keeps this auditable. Two things I'd add from running monitoring in payments. First, the scheduled re-evaluation plus window overlap will generate duplicate findings: a cron every 15 minutes over a 60-minute window re-flags the same structuring pattern three or four times before it ages out, and your findings table (and the analyst's queue) fills with the same alert. We ended up deduping on (customer, rule, transaction-set) so a pattern is one finding until the underlying set actually changes. Second, the behaviour-shift baseline has a blind spot: a brand new or newly-reactivated account has no meaningful average, and anyone deliberately structuring will establish a "normal" of near-threshold activity precisely so the relative rule never fires. Both the cold-start and the adversarial case need a floor rule that doesn't depend on the customer's own history.
Fair, the shared history service only solves half of this. Running it on a schedule with overlapping windows creates its own duplication problem if nothing dedupes the findings afterward. Keying dedup on customer, rule, and the actual transaction set is a clean way to keep one live finding per real pattern instead of the same thing getting re flagged every fifteen minutes until it finally ages out. The cold start and adversarial point is the one that really stuck with me. A relative baseline works fine for an established customer, but there is nothing to compare against for a brand new account, and anyone structuring on purpose would naturally keep their activity inside their own normal range just to dodge a relative rule. A floor rule that does not depend on the customer's own history is the right way to close that gap, since it catches exactly what the relative rule was never built to catch. This is a real hole in what I wrote, appreciate you calling it out.