An advanced exploration of SOAP protocol, its history, core concepts, and practical implementation with NestJS. Learn when SOAP still matters in modern distributed systems.

In an era dominated by REST APIs, GraphQL, and gRPC, SOAP (Simple Object Access Protocol) might seem like a relic from the early 2000s. Yet, SOAP continues to power critical enterprise systems, financial transactions, and government services worldwide. Understanding SOAP isn't just about maintaining legacy systems—it's about recognizing when strict contracts, built-in security, and transactional reliability matter more than developer convenience.
This deep dive explores SOAP's architecture, compares it against modern alternatives, and demonstrates a production-grade implementation using NestJS. If you've ever wondered why banks still use SOAP or when you should choose it over REST, this article provides the answers.
SOAP emerged in 1998 when Microsoft and DevelopMentor collaborated to solve a fundamental problem: how to enable structured communication between distributed systems over HTTP. Before SOAP, remote procedure calls (RPC) relied on proprietary protocols like DCOM and CORBA, which struggled with firewalls and lacked platform independence.
SOAP's key innovation was using XML over HTTP, making it firewall-friendly and platform-agnostic. The W3C standardized SOAP in 2003, establishing it as the foundation for web services alongside WSDL (Web Services Description Language) and UDDI (Universal Description, Discovery, and Integration).
SOAP introduced several concepts that were groundbreaking for distributed computing:
Understanding when to use SOAP requires comparing it against modern alternatives. Each protocol solves different problems.
REST (Representational State Transfer) dominates modern web APIs, but SOAP offers distinct advantages in specific scenarios.
| Aspect | SOAP | REST |
|---|---|---|
| Contract | Strict WSDL schema | Optional OpenAPI/Swagger |
| Data Format | XML only | JSON, XML, others |
| State | Can be stateful | Stateless by design |
| Security | WS-Security built-in | OAuth, JWT (external) |
| Transactions | WS-AtomicTransaction | Application-level |
| Error Handling | Standardized faults | HTTP status codes |
| Tooling | Auto-generated clients | Manual or codegen |
| Performance | Heavier (XML parsing) | Lighter (JSON) |
| Caching | Limited | HTTP caching |
Use SOAP when: You need guaranteed message delivery, ACID transactions across services, or formal contracts with automatic validation.
Use REST when: You need simplicity, caching, stateless operations, or public-facing APIs.
GraphQL revolutionized data fetching by letting clients specify exactly what they need. However, it serves a different purpose than SOAP.
| Aspect | SOAP | GraphQL |
|---|---|---|
| Query Flexibility | Fixed operations | Client-defined queries |
| Schema | WSDL (XML) | SDL (GraphQL Schema) |
| Versioning | Explicit versions | Schema evolution |
| Real-time | WS-Notification | Subscriptions |
| Complexity | High learning curve | Moderate complexity |
| Use Case | Enterprise integration | Frontend data fetching |
Use SOAP when: You're integrating with enterprise systems that require formal contracts and don't need flexible querying.
Use GraphQL when: You're building client-facing APIs where different clients need different data shapes.
gRPC represents modern RPC done right, using Protocol Buffers and HTTP/2. It's SOAP's spiritual successor for high-performance scenarios.
| Aspect | SOAP | gRPC |
|---|---|---|
| Encoding | XML (text) | Protocol Buffers (binary) |
| Transport | HTTP/1.1, SMTP, TCP | HTTP/2 only |
| Streaming | Limited | Bidirectional streaming |
| Performance | Slower (XML overhead) | Faster (binary, multiplexing) |
| Browser Support | Yes (via HTTP) | Limited (needs proxy) |
| Maturity | 25+ years | ~8 years |
| Ecosystem | Extensive (legacy) | Growing rapidly |
Use SOAP when: You need to integrate with existing SOAP services or require WS-* standards (security, transactions).
Use gRPC when: You're building microservices that need high performance, streaming, or strong typing without legacy constraints.
Despite its age, SOAP remains the right choice in specific domains:
Banks and payment processors use SOAP because:
Real-world example: SWIFT (Society for Worldwide Interbank Financial Telecommunication) uses SOAP for international money transfers.
Medical record exchanges rely on SOAP for:
Government agencies favor SOAP because:
Telecom operators use SOAP for:
Understanding SOAP requires grasping its fundamental building blocks.
A SOAP message consists of an XML envelope with optional headers and a mandatory body:
<?xml version="1.0"?>
<soap:Envelope
xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
xmlns:example="http://example.com/payment">
<soap:Header>
<example:Authentication>
<example:Token>abc123xyz</example:Token>
</example:Authentication>
</soap:Header>
<soap:Body>
<example:ProcessPayment>
<example:Amount>100.00</example:Amount>
<example:Currency>USD</example:Currency>
</example:ProcessPayment>
</soap:Body>
</soap:Envelope>Envelope: The root element that identifies the XML as a SOAP message. It defines namespaces and encoding rules.
Header: Optional element for metadata like authentication, routing, or transaction context. Headers can be marked as mustUnderstand="true" to force processing.
Body: Contains the actual request or response data. Only one body element is allowed per message.
Fault: When errors occur, the body contains a fault element instead of the normal response.
SOAP standardizes error reporting through fault elements:
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<soap:Fault>
<soap:Code>
<soap:Value>soap:Sender</soap:Value>
</soap:Code>
<soap:Reason>
<soap:Text xml:lang="en">Invalid payment amount</soap:Text>
</soap:Reason>
<soap:Detail>
<example:PaymentError>
<example:ErrorCode>INVALID_AMOUNT</example:ErrorCode>
<example:MinAmount>1.00</example:MinAmount>
</example:PaymentError>
</soap:Detail>
</soap:Fault>
</soap:Body>
</soap:Envelope>Fault codes indicate who's responsible:
soap:Sender: Client error (like HTTP 4xx)soap:Receiver: Server error (like HTTP 5xx)soap:MustUnderstand: Required header not processedsoap:VersionMismatch: SOAP version incompatibilityWSDL is SOAP's contract language. It defines:
Think of WSDL as the equivalent of OpenAPI/Swagger for REST, but with stronger typing and automatic client generation.
SOAP's real power comes from WS-* extensions:
WS-Security: Encryption, signatures, and authentication at the message level (not just transport). This means messages stay secure even when passing through intermediaries.
WS-ReliableMessaging: Guarantees message delivery with acknowledgments and retries. Critical for financial transactions.
WS-AtomicTransaction: Coordinates ACID transactions across multiple services. If one service fails, all rollback.
WS-Addressing: Adds routing information to messages, enabling asynchronous communication patterns.
WS-Policy: Declares service requirements and capabilities, allowing clients to negotiate features.
These standards solve problems that REST developers often reinvent poorly. However, they add significant complexity.
SOAP supports two encoding styles that affect how data is serialized:
The modern, recommended approach. The SOAP body contains an XML document that validates against an XML Schema:
<soap:Body>
<ns:CreateOrder xmlns:ns="http://example.com/orders">
<ns:Order>
<ns:CustomerId>12345</ns:CustomerId>
<ns:Items>
<ns:Item>
<ns:ProductId>ABC</ns:ProductId>
<ns:Quantity>2</ns:Quantity>
</ns:Item>
</ns:Items>
</ns:Order>
</ns:CreateOrder>
</soap:Body>Advantages: Clean, validates against schema, interoperable, easier to version.
The older style that encodes method calls with type information:
<soap:Body>
<ns:CreateOrder xmlns:ns="http://example.com/orders">
<customerId xsi:type="xsd:int">12345</customerId>
<items xsi:type="ns:ArrayOfItem">
<item xsi:type="ns:Item">
<productId xsi:type="xsd:string">ABC</productId>
<quantity xsi:type="xsd:int">2</quantity>
</item>
</items>
</ns:CreateOrder>
</soap:Body>Disadvantages: Verbose, harder to validate, poor interoperability. Avoid in new projects.
Let's build a production-grade SOAP service using NestJS. We'll create a payment processing service that demonstrates real-world patterns.
First, install dependencies:
npm i -g @nestjs/cli
nest new soap-payment-service
cd soap-payment-serviceCreate a WSDL file that defines our payment service contract:
This WSDL defines a payment service with two operations: processing payments and issuing refunds. Notice the document/literal style for maximum interoperability.
Define TypeScript types matching our WSDL schema:
export enum PaymentMethod {
CREDIT_CARD = 'CREDIT_CARD',
DEBIT_CARD = 'DEBIT_CARD',
BANK_TRANSFER = 'BANK_TRANSFER',
}
export interface ProcessPaymentRequest {
transactionId: string;
amount: number;
currency: string;
customerId: string;
paymentMethod: PaymentMethod;
}
export interface ProcessPaymentResponse {
success: boolean;
transactionId: string;
authorizationCode: string;
timestamp: Date;
}
export interface RefundPaymentRequest {
originalTransactionId: string;
amount: number;
reason: string;
}
export interface RefundPaymentResponse {
success: boolean;
refundId: string;
timestamp: Date;
}Create the business logic for payment processing:
import { Injectable, Logger } from '@nestjs/common';
import {
ProcessPaymentRequest,
ProcessPaymentResponse,
RefundPaymentRequest,
RefundPaymentResponse,
} from './dto/payment.dto';
import { randomUUID } from 'crypto';
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
// Simulate payment gateway integration
private readonly processedPayments = new Map<string, ProcessPaymentResponse>();
async processPayment(
request: ProcessPaymentRequest,
): Promise<ProcessPaymentResponse> {
this.logger.log(`Processing payment: ${JSON.stringify(request)}`);
// Validate amount
if (request.amount <= 0) {
throw new Error('Amount must be greater than zero');
}
// Validate currency
const validCurrencies = ['USD', 'EUR', 'GBP'];
if (!validCurrencies.includes(request.currency)) {
throw new Error(`Invalid currency: ${request.currency}`);
}
// Simulate payment gateway call
await this.simulateGatewayDelay();
// Generate authorization code
const authorizationCode = this.generateAuthCode();
const response: ProcessPaymentResponse = {
success: true,
transactionId: request.transactionId,
authorizationCode,
timestamp: new Date(),
};
// Store for refund lookup
this.processedPayments.set(request.transactionId, response);
this.logger.log(`Payment processed successfully: ${authorizationCode}`);
return response;
}
async refundPayment(
request: RefundPaymentRequest,
): Promise<RefundPaymentResponse> {
this.logger.log(`Processing refund: ${JSON.stringify(request)}`);
// Verify original transaction exists
const originalPayment = this.processedPayments.get(
request.originalTransactionId,
);
if (!originalPayment) {
throw new Error(
`Original transaction not found: ${request.originalTransactionId}`,
);
}
// Validate refund amount
if (request.amount <= 0) {
throw new Error('Refund amount must be greater than zero');
}
// Simulate refund processing
await this.simulateGatewayDelay();
const response: RefundPaymentResponse = {
success: true,
refundId: randomUUID(),
timestamp: new Date(),
};
this.logger.log(`Refund processed successfully: ${response.refundId}`);
return response;
}
private generateAuthCode(): string {
return `AUTH-${Date.now()}-${Math.random().toString(36).substring(7).toUpperCase()}`;
}
private async simulateGatewayDelay(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 100));
}
}This service implements realistic payment processing with validation, error handling, and simulated gateway integration.
Build a controller that exposes SOAP endpoints:
import { Controller, Post, Req, Res, Get, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import * as soap from 'soap';
import { PaymentService } from './payment.service';
import { readFileSync } from 'fs';
import { join } from 'path';
@Controller('soap/payment')
export class PaymentSoapController {
private readonly logger = new Logger(PaymentSoapController.name);
private soapServer: soap.Server;
constructor(private readonly paymentService: PaymentService) {}
@Get()
getWsdl(@Res() res: Response) {
const wsdlPath = join(__dirname, '../../wsdl/payment-service.wsdl');
const wsdl = readFileSync(wsdlPath, 'utf8');
res.type('application/xml');
res.send(wsdl);
}
@Post()
async handleSoapRequest(@Req() req: Request, @Res() res: Response) {
if (!this.soapServer) {
await this.initializeSoapServer();
}
// Let soap library handle the request
this.soapServer.emit('request', req, res);
}
private async initializeSoapServer() {
const wsdlPath = join(__dirname, '../../wsdl/payment-service.wsdl');
const wsdl = readFileSync(wsdlPath, 'utf8');
const serviceImplementation = {
PaymentService: {
PaymentServicePort: {
ProcessPayment: async (args: any) => {
try {
this.logger.log('SOAP ProcessPayment called');
const request = args.parameters;
const response = await this.paymentService.processPayment({
transactionId: request.transactionId,
amount: parseFloat(request.amount),
currency: request.currency,
customerId: request.customerId,
paymentMethod: request.paymentMethod,
});
return {
parameters: {
success: response.success,
transactionId: response.transactionId,
authorizationCode: response.authorizationCode,
timestamp: response.timestamp.toISOString(),
},
};
} catch (error) {
this.logger.error(`ProcessPayment error: ${error.message}`);
throw {
Fault: {
Code: {
Value: 'soap:Sender',
},
Reason: {
Text: error.message,
},
Detail: {
ErrorCode: 'PAYMENT_PROCESSING_ERROR',
},
},
};
}
},
RefundPayment: async (args: any) => {
try {
this.logger.log('SOAP RefundPayment called');
const request = args.parameters;
const response = await this.paymentService.refundPayment({
originalTransactionId: request.originalTransactionId,
amount: parseFloat(request.amount),
reason: request.reason,
});
return {
parameters: {
success: response.success,
refundId: response.refundId,
timestamp: response.timestamp.toISOString(),
},
};
} catch (error) {
this.logger.error(`RefundPayment error: ${error.message}`);
throw {
Fault: {
Code: {
Value: 'soap:Sender',
},
Reason: {
Text: error.message,
},
Detail: {
ErrorCode: 'REFUND_PROCESSING_ERROR',
},
},
};
}
},
},
},
};
this.soapServer = soap.listen(null, '/soap/payment', serviceImplementation, wsdl);
this.logger.log('SOAP server initialized');
}
}The controller handles both WSDL retrieval and SOAP request processing. Notice how errors are converted to proper SOAP faults.
Wire everything together in a NestJS module:
import { Module } from '@nestjs/common';
import { PaymentService } from './payment.service';
import { PaymentSoapController } from './payment-soap.controller';
@Module({
controllers: [PaymentSoapController],
providers: [PaymentService],
exports: [PaymentService],
})
export class PaymentModule {}import { Module } from '@nestjs/common';
import { PaymentModule } from './payment/payment.module';
@Module({
imports: [PaymentModule],
})
export class AppModule {}SOAP requires XML parsing. Configure Express middleware:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as xmlparser from 'express-xml-bodyparser';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable XML body parsing for SOAP
app.use(xmlparser());
await app.listen(3000);
console.log('SOAP Payment Service running on http://localhost:3000');
console.log('WSDL available at http://localhost:3000/soap/payment');
}
bootstrap();Create a test client to verify the implementation:
import * as soap from 'soap';
async function testPaymentService() {
const url = 'http://localhost:3000/soap/payment';
try {
// Create SOAP client
const client = await soap.createClientAsync(url);
console.log('Available methods:', Object.keys(client));
// Test ProcessPayment
console.log('\n--- Testing ProcessPayment ---');
const paymentRequest = {
parameters: {
transactionId: 'TXN-' + Date.now(),
amount: 99.99,
currency: 'USD',
customerId: 'CUST-12345',
paymentMethod: 'CREDIT_CARD',
},
};
const paymentResult = await client.ProcessPaymentAsync(paymentRequest);
console.log('Payment Result:', JSON.stringify(paymentResult, null, 2));
// Test RefundPayment
console.log('\n--- Testing RefundPayment ---');
const refundRequest = {
parameters: {
originalTransactionId: paymentRequest.parameters.transactionId,
amount: 99.99,
reason: 'Customer requested refund',
},
};
const refundResult = await client.RefundPaymentAsync(refundRequest);
console.log('Refund Result:', JSON.stringify(refundResult, null, 2));
// Test error handling
console.log('\n--- Testing Error Handling ---');
try {
await client.ProcessPaymentAsync({
parameters: {
transactionId: 'TXN-ERROR',
amount: -10, // Invalid amount
currency: 'USD',
customerId: 'CUST-12345',
paymentMethod: 'CREDIT_CARD',
},
});
} catch (error) {
console.log('Expected error:', error.message);
}
} catch (error) {
console.error('Test failed:', error);
}
}
testPaymentService();Run the tests:
npm run start:devExpected output:
Available methods: [ 'ProcessPayment', 'RefundPayment' ]
--- Testing ProcessPayment ---
Payment Result: {
"parameters": {
"success": true,
"transactionId": "TXN-1709395200000",
"authorizationCode": "AUTH-1709395200123-X7K9P",
"timestamp": "2026-03-02T10:00:00.000Z"
}
}
--- Testing RefundPayment ---
Refund Result: {
"parameters": {
"success": true,
"refundId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-03-02T10:00:01.000Z"
}
}
--- Testing Error Handling ---
Expected error: Amount must be greater than zeroFor production systems, implement WS-Security for authentication and encryption:
import * as crypto from 'crypto';
export class WsSecurityHandler {
private readonly validTokens = new Set<string>();
constructor(private readonly secretKey: string) {}
generateToken(username: string): string {
const timestamp = Date.now();
const nonce = crypto.randomBytes(16).toString('base64');
const digest = this.createDigest(username, timestamp, nonce);
const token = Buffer.from(
JSON.stringify({ username, timestamp, nonce, digest })
).toString('base64');
this.validTokens.add(token);
return token;
}
validateToken(token: string): boolean {
try {
const decoded = JSON.parse(
Buffer.from(token, 'base64').toString('utf8')
);
// Check token age (5 minutes max)
const age = Date.now() - decoded.timestamp;
if (age > 5 * 60 * 1000) {
return false;
}
// Verify digest
const expectedDigest = this.createDigest(
decoded.username,
decoded.timestamp,
decoded.nonce
);
return decoded.digest === expectedDigest;
} catch {
return false;
}
}
private createDigest(username: string, timestamp: number, nonce: string): string {
const data = `${username}:${timestamp}:${nonce}:${this.secretKey}`;
return crypto.createHash('sha256').update(data).digest('base64');
}
}Integrate security into the controller:
import { Controller, Post, Req, Res, Get, Logger, UnauthorizedException } from '@nestjs/common';
import { Request, Response } from 'express';
import * as soap from 'soap';
import { WsSecurityHandler } from './security/ws-security';
@Controller('soap/payment')
export class PaymentSoapController {
private readonly logger = new Logger(PaymentSoapController.name);
private soapServer: soap.Server;
private readonly security: WsSecurityHandler;
constructor(private readonly paymentService: PaymentService) {
this.security = new WsSecurityHandler(process.env.WS_SECURITY_KEY || 'default-secret');
}
@Post()
async handleSoapRequest(@Req() req: Request, @Res() res: Response) {
// Extract and validate WS-Security header
const authHeader = req.headers['x-ws-security-token'];
if (!authHeader || !this.security.validateToken(authHeader as string)) {
this.logger.warn('Unauthorized SOAP request');
res.status(401).send(this.createSoapFault('Unauthorized', 'soap:Sender'));
return;
}
if (!this.soapServer) {
await this.initializeSoapServer();
}
this.soapServer.emit('request', req, res);
}
private createSoapFault(message: string, code: string): string {
return `<?xml version="1.0"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<soap:Fault>
<soap:Code><soap:Value>${code}</soap:Value></soap:Code>
<soap:Reason><soap:Text>${message}</soap:Text></soap:Reason>
</soap:Fault>
</soap:Body>
</soap:Envelope>`;
}
}Many legacy tutorials still recommend RPC/encoded, but it causes interoperability issues.
Wrong:
<soap:Body soap:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<m:GetPrice xmlns:m="http://example.com">
<m:Item xsi:type="xsd:string">Widget</m:Item>
</m:GetPrice>
</soap:Body>Right:
<soap:Body>
<GetPriceRequest xmlns="http://example.com">
<Item>Widget</Item>
</GetPriceRequest>
</soap:Body>Always use document/literal style with proper XML Schema validation.
Returning HTTP 500 with plain text errors breaks SOAP clients.
Wrong:
throw new Error('Payment failed');Right:
throw {
Fault: {
Code: { Value: 'soap:Sender' },
Reason: { Text: 'Payment failed' },
Detail: { ErrorCode: 'INSUFFICIENT_FUNDS' }
}
};Accepting any XML structure defeats SOAP's type safety.
Solution: Use XML Schema validation:
import { parseString } from 'xml2js';
import { validate } from 'xsd-schema-validator';
async function validateSoapRequest(xml: string, xsdPath: string): Promise<boolean> {
try {
const result = await validate(xml, xsdPath);
return result.valid;
} catch (error) {
throw new Error(`Schema validation failed: ${error.message}`);
}
}Don't try to make SOAP RESTful or vice versa.
Wrong: Using HTTP status codes for business logic in SOAP
if (paymentFailed) {
res.status(402).send(soapResponse); // Don't do this
}Right: Always return HTTP 200 for successful SOAP processing, use SOAP faults for errors
res.status(200).send(soapFaultResponse);XML namespaces are critical in SOAP. Mismatched namespaces cause parsing failures.
Wrong:
<ProcessPayment>
<Amount>100</Amount>
</ProcessPayment>Right:
<tns:ProcessPayment xmlns:tns="http://example.com/payment">
<tns:Amount>100</tns:Amount>
</tns:ProcessPayment>Include version numbers in namespaces and service names:
<definitions
targetNamespace="http://example.com/payment/v2"
name="PaymentServiceV2">This allows running multiple versions simultaneously during migrations.
Log every SOAP request and response for audit trails:
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class SoapLoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const startTime = Date.now();
// Capture request body
let requestBody = '';
req.on('data', chunk => requestBody += chunk);
// Capture response
const originalSend = res.send;
res.send = function(data) {
const duration = Date.now() - startTime;
console.log({
timestamp: new Date().toISOString(),
method: req.method,
url: req.url,
requestBody,
responseBody: data,
duration,
statusCode: res.statusCode,
});
return originalSend.call(this, data);
};
next();
}
}When calling other SOAP services, reuse connections:
import * as soap from 'soap';
export class SoapClientPool {
private clients = new Map<string, soap.Client>();
async getClient(wsdlUrl: string): Promise<soap.Client> {
if (!this.clients.has(wsdlUrl)) {
const client = await soap.createClientAsync(wsdlUrl, {
connection: 'keep-alive',
timeout: 30000,
});
this.clients.set(wsdlUrl, client);
}
return this.clients.get(wsdlUrl);
}
}Financial operations must be idempotent to handle retries safely:
@Injectable()
export class PaymentService {
private readonly processedTransactions = new Map<string, ProcessPaymentResponse>();
async processPayment(request: ProcessPaymentRequest): Promise<ProcessPaymentResponse> {
// Check if already processed
const existing = this.processedTransactions.get(request.transactionId);
if (existing) {
this.logger.log(`Returning cached result for ${request.transactionId}`);
return existing;
}
// Process payment
const response = await this.executePayment(request);
// Cache result
this.processedTransactions.set(request.transactionId, response);
return response;
}
}SOAP operations can be slow. Configure timeouts at multiple levels:
const client = await soap.createClientAsync(wsdlUrl, {
timeout: 30000, // 30 seconds for network
connectionTimeout: 5000, // 5 seconds to establish connection
});
// Per-operation timeout
client.setTimeout(60000); // 60 seconds for long operationsTrack SOAP-specific metrics:
import { Injectable } from '@nestjs/common';
@Injectable()
export class SoapMetricsService {
private metrics = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
averageResponseTime: 0,
operationCounts: new Map<string, number>(),
};
recordRequest(operation: string, duration: number, success: boolean) {
this.metrics.totalRequests++;
if (success) {
this.metrics.successfulRequests++;
} else {
this.metrics.failedRequests++;
}
// Update average response time
this.metrics.averageResponseTime =
(this.metrics.averageResponseTime * (this.metrics.totalRequests - 1) + duration)
/ this.metrics.totalRequests;
// Track per-operation counts
const count = this.metrics.operationCounts.get(operation) || 0;
this.metrics.operationCounts.set(operation, count + 1);
}
getMetrics() {
return {
...this.metrics,
successRate: (this.metrics.successfulRequests / this.metrics.totalRequests) * 100,
};
}
}Despite its strengths, SOAP is the wrong choice in many scenarios:
If you're building a public API for web or mobile apps, REST or GraphQL are better choices. SOAP's complexity creates friction for external developers who just want to fetch data quickly.
Why: Public APIs prioritize developer experience, documentation simplicity, and ease of integration. SOAP's WSDL files and XML verbosity create unnecessary barriers.
WebSocket-based protocols or Server-Sent Events are better for real-time updates. While WS-Notification exists, it's rarely implemented correctly.
Why: SOAP's request-response model adds latency. Modern real-time protocols are designed for bidirectional streaming.
For internal microservices, gRPC offers better performance with similar type safety. SOAP's XML overhead becomes significant at scale.
Why: Microservices need low latency and efficient serialization. gRPC's binary Protocol Buffers are 5-10x faster than XML parsing.
If you're just reading and writing database records, REST with JSON is simpler and more maintainable.
Why: SOAP's complexity only pays off when you need its advanced features. For basic operations, it's overkill.
Cold start times in AWS Lambda or similar platforms make SOAP's heavy XML parsing problematic.
Why: Serverless functions need fast initialization. SOAP libraries and XML parsers add significant startup overhead.
Let's extend our payment service to integrate with a fictional bank's SOAP API, demonstrating a realistic enterprise scenario.
Your payment service needs to verify account balances before processing payments by calling a bank's SOAP service. The bank requires WS-Security authentication and provides a WSDL contract.
import { Injectable, Logger } from '@nestjs/common';
import * as soap from 'soap';
export interface AccountBalanceRequest {
accountNumber: string;
customerId: string;
}
export interface AccountBalanceResponse {
accountNumber: string;
availableBalance: number;
currency: string;
lastUpdated: Date;
}
@Injectable()
export class BankSoapClient {
private readonly logger = new Logger(BankSoapClient.name);
private client: soap.Client;
private readonly wsdlUrl = process.env.BANK_WSDL_URL || 'http://bank-api.example.com/soap?wsdl';
async initialize() {
if (!this.client) {
this.client = await soap.createClientAsync(this.wsdlUrl, {
timeout: 30000,
connectionTimeout: 5000,
});
// Add WS-Security
const wsSecurity = new soap.WSSecurity(
process.env.BANK_USERNAME,
process.env.BANK_PASSWORD,
{
hasTimeStamp: true,
hasTokenCreated: true,
}
);
this.client.setSecurity(wsSecurity);
this.logger.log('Bank SOAP client initialized');
}
}
async getAccountBalance(request: AccountBalanceRequest): Promise<AccountBalanceResponse> {
await this.initialize();
try {
const result = await this.client.GetAccountBalanceAsync({
parameters: {
accountNumber: request.accountNumber,
customerId: request.customerId,
},
});
const response = result[0].parameters;
return {
accountNumber: response.accountNumber,
availableBalance: parseFloat(response.availableBalance),
currency: response.currency,
lastUpdated: new Date(response.lastUpdated),
};
} catch (error) {
this.logger.error(`Bank API error: ${error.message}`);
throw new Error(`Failed to retrieve account balance: ${error.message}`);
}
}
async verifyFunds(accountNumber: string, customerId: string, requiredAmount: number): Promise<boolean> {
const balance = await this.getAccountBalance({ accountNumber, customerId });
return balance.availableBalance >= requiredAmount;
}
}import { Injectable, Logger } from '@nestjs/common';
import {
ProcessPaymentRequest,
ProcessPaymentResponse,
} from './dto/payment.dto';
import { BankSoapClient } from '../integrations/bank/bank-soap.client';
import { randomUUID } from 'crypto';
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
constructor(private readonly bankClient: BankSoapClient) {}
async processPayment(
request: ProcessPaymentRequest,
): Promise<ProcessPaymentResponse> {
this.logger.log(`Processing payment: ${JSON.stringify(request)}`);
// Validate amount
if (request.amount <= 0) {
throw new Error('Amount must be greater than zero');
}
// For bank transfers, verify funds via SOAP call
if (request.paymentMethod === 'BANK_TRANSFER') {
const hasFunds = await this.bankClient.verifyFunds(
request.customerId, // Using customerId as account number for demo
request.customerId,
request.amount,
);
if (!hasFunds) {
throw new Error('Insufficient funds in account');
}
this.logger.log('Funds verified successfully');
}
// Simulate payment processing
await this.simulateGatewayDelay();
const authorizationCode = this.generateAuthCode();
const response: ProcessPaymentResponse = {
success: true,
transactionId: request.transactionId,
authorizationCode,
timestamp: new Date(),
};
this.logger.log(`Payment processed successfully: ${authorizationCode}`);
return response;
}
private generateAuthCode(): string {
return `AUTH-${Date.now()}-${Math.random().toString(36).substring(7).toUpperCase()}`;
}
private async simulateGatewayDelay(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 100));
}
}Protect your service from bank API failures:
import { Injectable, Logger } from '@nestjs/common';
enum CircuitState {
CLOSED = 'CLOSED',
OPEN = 'OPEN',
HALF_OPEN = 'HALF_OPEN',
}
@Injectable()
export class CircuitBreaker {
private readonly logger = new Logger(CircuitBreaker.name);
private state: CircuitState = CircuitState.CLOSED;
private failureCount = 0;
private lastFailureTime: number = 0;
private readonly failureThreshold = 5;
private readonly resetTimeout = 60000; // 1 minute
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === CircuitState.OPEN) {
if (Date.now() - this.lastFailureTime > this.resetTimeout) {
this.logger.log('Circuit breaker entering HALF_OPEN state');
this.state = CircuitState.HALF_OPEN;
} else {
throw new Error('Circuit breaker is OPEN - service unavailable');
}
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess() {
this.failureCount = 0;
if (this.state === CircuitState.HALF_OPEN) {
this.logger.log('Circuit breaker closing after successful call');
this.state = CircuitState.CLOSED;
}
}
private onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.logger.warn(`Circuit breaker opening after ${this.failureCount} failures`);
this.state = CircuitState.OPEN;
}
}
getState(): CircuitState {
return this.state;
}
}Integrate the circuit breaker:
@Injectable()
export class BankSoapClient {
private readonly logger = new Logger(BankSoapClient.name);
private client: soap.Client;
private readonly circuitBreaker = new CircuitBreaker();
async getAccountBalance(request: AccountBalanceRequest): Promise<AccountBalanceResponse> {
return this.circuitBreaker.execute(async () => {
await this.initialize();
const result = await this.client.GetAccountBalanceAsync({
parameters: request,
});
return this.parseBalanceResponse(result);
});
}
}Create integration tests with mocked SOAP responses:
import { Test, TestingModule } from '@nestjs/testing';
import { BankSoapClient } from './bank-soap.client';
import * as soap from 'soap';
jest.mock('soap');
describe('BankSoapClient', () => {
let client: BankSoapClient;
let mockSoapClient: any;
beforeEach(async () => {
mockSoapClient = {
GetAccountBalanceAsync: jest.fn(),
setSecurity: jest.fn(),
};
(soap.createClientAsync as jest.Mock).mockResolvedValue(mockSoapClient);
const module: TestingModule = await Test.createTestingModule({
providers: [BankSoapClient],
}).compile();
client = module.get<BankSoapClient>(BankSoapClient);
});
describe('getAccountBalance', () => {
it('should return account balance successfully', async () => {
const mockResponse = [{
parameters: {
accountNumber: 'ACC-12345',
availableBalance: '1000.00',
currency: 'USD',
lastUpdated: '2026-03-02T10:00:00Z',
},
}];
mockSoapClient.GetAccountBalanceAsync.mockResolvedValue(mockResponse);
const result = await client.getAccountBalance({
accountNumber: 'ACC-12345',
customerId: 'CUST-12345',
});
expect(result).toEqual({
accountNumber: 'ACC-12345',
availableBalance: 1000.00,
currency: 'USD',
lastUpdated: expect.any(Date),
});
});
it('should handle SOAP faults', async () => {
mockSoapClient.GetAccountBalanceAsync.mockRejectedValue(
new Error('Account not found')
);
await expect(
client.getAccountBalance({
accountNumber: 'INVALID',
customerId: 'CUST-12345',
})
).rejects.toThrow('Failed to retrieve account balance');
});
});
describe('verifyFunds', () => {
it('should return true when sufficient funds', async () => {
mockSoapClient.GetAccountBalanceAsync.mockResolvedValue([{
parameters: {
accountNumber: 'ACC-12345',
availableBalance: '1000.00',
currency: 'USD',
lastUpdated: '2026-03-02T10:00:00Z',
},
}]);
const result = await client.verifyFunds('ACC-12345', 'CUST-12345', 500);
expect(result).toBe(true);
});
it('should return false when insufficient funds', async () => {
mockSoapClient.GetAccountBalanceAsync.mockResolvedValue([{
parameters: {
accountNumber: 'ACC-12345',
availableBalance: '100.00',
currency: 'USD',
lastUpdated: '2026-03-02T10:00:00Z',
},
}]);
const result = await client.verifyFunds('ACC-12345', 'CUST-12345', 500);
expect(result).toBe(false);
});
});
});Run tests:
npm run testSOAP's XML overhead requires careful optimization for high-throughput systems.
Compress SOAP messages to reduce bandwidth:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as compression from 'compression';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable GZIP compression
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
threshold: 1024, // Only compress responses > 1KB
}));
await app.listen(3000);
}
bootstrap();GZIP typically reduces SOAP message size by 70-80%.
For large data transfers, stream XML instead of buffering:
import { createReadStream } from 'fs';
import { pipeline } from 'stream/promises';
@Get('large-report')
async getLargeReport(@Res() res: Response) {
res.setHeader('Content-Type', 'application/soap+xml');
const xmlStream = createReadStream('./large-report.xml');
await pipeline(xmlStream, res);
}Parse WSDL files once at startup, not per request:
@Injectable()
export class WsdlCacheService {
private readonly cache = new Map<string, any>();
async getWsdl(path: string): Promise<any> {
if (!this.cache.has(path)) {
const wsdl = await this.parseWsdl(path);
this.cache.set(path, wsdl);
}
return this.cache.get(path);
}
private async parseWsdl(path: string): Promise<any> {
// Parse WSDL once
return readFileSync(path, 'utf8');
}
}Combine multiple operations into a single SOAP call:
<soap:Body>
<BatchRequest xmlns="http://example.com/payment">
<Operations>
<ProcessPayment>
<transactionId>TXN-001</transactionId>
<amount>100.00</amount>
</ProcessPayment>
<ProcessPayment>
<transactionId>TXN-002</transactionId>
<amount>200.00</amount>
</ProcessPayment>
</Operations>
</BatchRequest>
</soap:Body>This reduces network round trips significantly.
Production SOAP services require comprehensive monitoring.
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class SoapLogger {
private readonly logger = new Logger('SOAP');
logRequest(operation: string, request: any, correlationId: string) {
this.logger.log({
event: 'soap_request',
operation,
correlationId,
timestamp: new Date().toISOString(),
requestSize: JSON.stringify(request).length,
});
}
logResponse(operation: string, response: any, correlationId: string, duration: number) {
this.logger.log({
event: 'soap_response',
operation,
correlationId,
timestamp: new Date().toISOString(),
duration,
responseSize: JSON.stringify(response).length,
success: true,
});
}
logError(operation: string, error: Error, correlationId: string, duration: number) {
this.logger.error({
event: 'soap_error',
operation,
correlationId,
timestamp: new Date().toISOString(),
duration,
error: error.message,
stack: error.stack,
});
}
}Implement health endpoints for monitoring:
import { Controller, Get } from '@nestjs/common';
import { BankSoapClient } from '../integrations/bank/bank-soap.client';
@Controller('health')
export class HealthController {
constructor(private readonly bankClient: BankSoapClient) {}
@Get()
async check() {
const checks = {
status: 'ok',
timestamp: new Date().toISOString(),
services: {
bankApi: await this.checkBankApi(),
},
};
return checks;
}
private async checkBankApi(): Promise<{ status: string; latency?: number }> {
const start = Date.now();
try {
await this.bankClient.initialize();
return {
status: 'healthy',
latency: Date.now() - start,
};
} catch (error) {
return {
status: 'unhealthy',
latency: Date.now() - start,
};
}
}
}Export metrics for monitoring systems:
import { Injectable } from '@nestjs/common';
import { Counter, Histogram, register } from 'prom-client';
@Injectable()
export class MetricsService {
private readonly requestCounter = new Counter({
name: 'soap_requests_total',
help: 'Total number of SOAP requests',
labelNames: ['operation', 'status'],
});
private readonly requestDuration = new Histogram({
name: 'soap_request_duration_seconds',
help: 'SOAP request duration in seconds',
labelNames: ['operation'],
buckets: [0.1, 0.5, 1, 2, 5],
});
recordRequest(operation: string, duration: number, success: boolean) {
this.requestCounter.inc({
operation,
status: success ? 'success' : 'error',
});
this.requestDuration.observe({ operation }, duration / 1000);
}
async getMetrics(): Promise<string> {
return register.metrics();
}
}If you're stuck with SOAP but want to modernize, consider these approaches:
Create a REST facade over SOAP services:
@Controller('api/payments')
export class PaymentRestController {
constructor(private readonly soapClient: PaymentSoapClient) {}
@Post()
async createPayment(@Body() dto: CreatePaymentDto) {
// Convert REST request to SOAP
const soapRequest = {
parameters: {
transactionId: randomUUID(),
amount: dto.amount,
currency: dto.currency,
customerId: dto.customerId,
paymentMethod: dto.paymentMethod,
},
};
const soapResponse = await this.soapClient.processPayment(soapRequest);
// Convert SOAP response to REST
return {
id: soapResponse.transactionId,
authCode: soapResponse.authorizationCode,
timestamp: soapResponse.timestamp,
};
}
}This allows new clients to use REST while maintaining SOAP compatibility.
Gradually replace SOAP operations with modern alternatives:
Replace synchronous SOAP calls with asynchronous events:
@Injectable()
export class PaymentEventService {
constructor(
private readonly eventBus: EventBus,
private readonly soapService: PaymentService,
) {}
async processPaymentAsync(request: ProcessPaymentRequest) {
// Emit event instead of synchronous SOAP call
await this.eventBus.publish(
new PaymentRequestedEvent(request)
);
}
@OnEvent('payment.requested')
async handlePaymentRequested(event: PaymentRequestedEvent) {
// Process via SOAP in background
const result = await this.soapService.processPayment(event.request);
// Emit completion event
await this.eventBus.publish(
new PaymentCompletedEvent(result)
);
}
}This decouples clients from SOAP's synchronous nature.
SOAP isn't dead—it's just specialized. While REST, GraphQL, and gRPC dominate modern development, SOAP remains essential for enterprise integration, financial services, and systems requiring formal contracts with built-in security and transactions.
The key insights:
SOAP excels when you need guaranteed message delivery, ACID transactions, formal contracts, or integration with existing enterprise systems. Its WS-* standards solve problems that REST developers often reinvent poorly.
SOAP struggles with developer experience, performance, and flexibility. For public APIs, real-time applications, or simple CRUD operations, modern alternatives are better choices.
The NestJS implementation demonstrates that SOAP can coexist with modern frameworks. By understanding SOAP's strengths and limitations, you can make informed architectural decisions rather than dismissing it as legacy technology.
If you're building new systems, start with REST or gRPC. But if you're integrating with banks, healthcare systems, or government agencies, understanding SOAP isn't optional—it's essential.