A bank receives a payment instruction from another bank. Before a single dollar or pound moves, that incoming message has to be checked completely, the structure has to be correct, the amount has to make sense, the account references have to be valid, and nothing about the message can be trusted just because it arrived on a trusted network. This is the part of payment infrastructure that rarely gets discussed publicly, but it is exactly where a real financial system either holds up under pressure or quietly lets something wrong slip through.
ISO 20022 is the structured messaging standard that banks and payment networks worldwide are actively moving to right now, replacing older, looser formats across systems like Fedwire, SWIFT, and instant payment rails. The format itself is well documented. What is much less documented is how to actually validate these messages properly inside a real backend, before that data ever touches an account balance.
Why validation cannot be an afterthought here
A normal API might validate a request and move on. A payment message is different, because a mistake here does not just produce an error, it can move real money incorrectly. An incoming ISO 20022 message needs to be checked at several levels, its overall structure needs to match what is expected, required fields need to actually be present, amounts and currencies need to be sensible, and the message needs to be checked against messages already processed, so the same payment instruction cannot accidentally be applied twice.
NestJS gives you a natural place to enforce every one of these checks consistently, instead of leaving validation logic scattered across different parts of the codebase.
Modeling the incoming message as a strict structure
The first real step is representing the incoming payment message as an explicit, strongly typed structure, rather than accepting loosely shaped data and hoping the important fields happen to be there. Using class validator decorators means the shape of a valid message is defined once, clearly, and enforced automatically.
import { Type } from 'class-transformer';
import {
IsString,
IsNotEmpty,
IsNumber,
IsPositive,
IsISO4217CurrencyCode,
ValidateNested,
IsDateString,
} from 'class-validator';
export class GroupHeaderDto {
@IsString()
@IsNotEmpty()
messageId: string;
@IsDateString()
creationDateTime: string;
@IsNumber()
@IsPositive()
numberOfTransactions: number;
}
export class PaymentAmountDto {
@IsNumber()
@IsPositive()
amount: number;
@IsISO4217CurrencyCode()
currency: string;
}
export class PaymentInstructionDto {
@IsString()
@IsNotEmpty()
endToEndId: string;
@ValidateNested()
@Type(() => PaymentAmountDto)
amount: PaymentAmountDto;
@IsString()
@IsNotEmpty()
debtorAccount: string;
@IsString()
@IsNotEmpty()
creditorAccount: string;
}
export class Pacs008Dto {
@ValidateNested()
@Type(() => GroupHeaderDto)
groupHeader: GroupHeaderDto;
@ValidateNested({ each: true })
@Type(() => PaymentInstructionDto)
paymentInstructions: PaymentInstructionDto[];
}
This structure alone rules out a large category of problems before any business logic runs. A message missing a currency code, carrying a negative amount, or missing an account reference simply fails validation immediately, rather than silently reaching code that assumes the data is already correct.
Enforcing validation automatically with a pipe
Defining the structure is only useful if it is actually enforced on every incoming message. NestJS's built in validation pipe applies these checks automatically at the point the request enters the system, so no controller can accidentally skip this step.
import { ValidationPipe } from '@nestjs/common';
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
Setting forbidNonWhitelisted to true matters specifically for payment messages, since it means any unexpected field in the incoming message causes an immediate rejection, rather than silently being ignored. For financial data, silently ignoring something unexpected is far more dangerous than rejecting it outright.
Rejecting duplicate messages before they are processed
A message might arrive more than once, because of a retry, a network issue, or a resend from the sending system. Processing the same payment instruction twice is exactly the kind of mistake that leads to money moving when it should not. A dedicated service checking the message id from the group header, before any processing happens, protects against this directly.
@Injectable()
export class MessageDeduplicationService {
constructor(
@InjectRepository(ProcessedMessage)
private readonly processedRepo: Repository<ProcessedMessage>,
) {}
async assertNotProcessed(messageId: string): Promise<void> {
const existing = await this.processedRepo.findOne({
where: { messageId },
});
if (existing) {
throw new ConflictException(
`Message ${messageId} has already been processed`,
);
}
}
async markProcessed(messageId: string): Promise<void> {
await this.processedRepo.save({
messageId,
processedAt: new Date(),
});
}
}
Checking this before any payment instruction is applied, and recording it immediately once processing begins, means a duplicate message is caught and rejected cleanly, rather than resulting in the same transfer happening more than once.
Bringing validation together with a guard
A guard is a natural place to run these checks before a request is even allowed to reach the controller, keeping this discipline consistent no matter which endpoint receives the message.
@Injectable()
export class PaymentMessageGuard implements CanActivate {
constructor(
private readonly deduplicationService: MessageDeduplicationService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const messageId = request.body?.groupHeader?.messageId;
if (!messageId) {
throw new BadRequestException('Missing message id in group header');
}
await this.deduplicationService.assertNotProcessed(messageId);
return true;
}
}
With this guard in place, every incoming payment message is checked for a valid structure through the pipe, and checked for duplication through the guard, before the controller or any service touches an actual account balance.
The bigger picture
None of this complexity comes from NestJS itself, it comes from the nature of the message, a payment instruction carries real financial weight, and a mistake in handling it is not just a bug, it is money moving incorrectly. What NestJS gives you is a clean, enforceable place to put each layer of protection, strict typing for the message shape, a pipe that rejects anything malformed automatically, and a guard that stops duplicate processing before it happens.
Banks and payment providers are in the middle of this migration right now, and the backend systems handling these messages need to treat validation as a first class concern, not something added later once a problem has already occurred.
If your team is working through ISO 20022 integration and wants this handled with the rigor it actually requires, 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)